javascript
Browser uploads
Install
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presignerConfigure
Configure the endpoint, region, credential and bucket in this tool’s vocabulary. Path-style addressing is recommended for dotted bucket names and clients without nested wildcard TLS, but virtual-hosted addressing is also supported.
| In Browser uploads | Set it to |
|---|---|
| endpoint | https://s3.canada.popcloud.ca |
| region | canada |
| credentials.accessKeyId | PCAK00EXAMPLEKEYID00server side only — never ships to the browser |
| credentials.secretAccessKey | <your secret access key>server side only — never ships to the browser |
| forcePathStyle | true |
export AWS_ACCESS_KEY_ID=PCAK00EXAMPLEKEYID00
export AWS_SECRET_ACCESS_KEY=<your secret access key>
export AWS_ENDPOINT_URL=https://s3.canada.popcloud.ca
export AWS_REGION=canada
export POPCLOUD_BUCKET=pc-your-org-mediaThese are example values. Sign in and every snippet on this site fills in with your own endpoint, key and bucket.
Use it
Upload an object
// Ask your own API for a signed URL, then PUT the file straight to PopCloud.
// The Content-Type here must match the one your server signed, or the
// signature will not verify.
async function uploadFile(file) {
const response = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType: file.type }),
});
const { url, key } = await response.json();
const put = await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
return key;
}Sign an upload
// Your API endpoint. Authenticate the user, decide the key yourself, and pin
// the content type into the signature so the browser cannot upload something
// else under it.
async function createUploadUrl({ key, contentType }) {
return getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.POPCLOUD_BUCKET,
Key: key,
ContentType: contentType,
}),
{ expiresIn: 300 }, // short — the browser uses it immediately
);
}Report progress
// fetch() cannot report upload progress. XMLHttpRequest still can, and it is
// the only reason to reach for it here.
function uploadWithProgress(url, file, onProgress) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", url);
request.setRequestHeader("Content-Type", file.type);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total);
});
request.addEventListener("load", () =>
request.status < 300 ? resolve() : reject(new Error(`upload failed: ${request.status}`)),
);
request.addEventListener("error", () => reject(new Error("upload failed")));
request.send(file);
});
}Sign a download
// Reading back a private object works the same way: sign a GET and hand the
// URL to an <img>, <video> or download link.
async function createDownloadUrl({ key }) {
return getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.POPCLOUD_BUCKET, Key: key }),
{ expiresIn: 3600 },
);
}Get an access token
# The control plane authenticates people, not machines: there is no API key.
# Get an access token, and use it for the rest of the session.
TOKEN=$(curl -fsS -X POST "$API/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"…"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')Set the bucket's CORS rules
# Replaces the bucket's rules wholesale — send every origin you need, not just
# the new one. Read the current set first with GET if you are unsure.
#
# An origin is a scheme and host with no trailing slash, or "*". The dashboard's
# own rule is re-added for you if you leave it out, so the file browser keeps
# working from the screen you are editing.
curl -fsS -X PUT "$API/v1/buckets/$BUCKET/cors" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"rules": [
{
"name": "myApp",
"origins": ["https://app.example.com", "http://localhost:3000"],
"methods": ["GET", "PUT", "HEAD"],
"headers": ["*"],
"expose_headers": ["etag"],
"max_age_seconds": 3600
}
]
}'Read the current rules
curl -fsS "$API/v1/buckets/$BUCKET/cors" -H "Authorization: Bearer $TOKEN"What to watch for
These apply to Browser uploads specifically. The full list covers the platform.
- You’ll see
- A GET carrying If-None-Match, If-Match or If-Modified-Since returns 400 InvalidRequest. Expected behaviour is 304 Not Modified for a cache hit and 412 PreconditionFailed for a stale precondition.
- Why
- The conditional headers are not handled correctly on the read path. This is a defect, not a design decision.
- Instead
- Do not send conditional headers on GET. If you are putting a CDN in front of a bucket, cache on a fixed TTL rather than on revalidation, and version your object keys so a new key means new content. Conditional *writes* (If-None-Match on PUT) are separately unsupported.
- You’ll see
- A browser upload fails at the preflight — the OPTIONS request is refused and the real request never runs. PutBucketCors returns 405 MethodNotAllowed.
- Why
- Bucket configuration is owned by the control plane. Each bucket is provisioned with CORS rules for the dashboard origin; your own origins have to be added there, not through the S3 API.
- Instead
- Add your origin in the dashboard's bucket settings, or with `PUT /v1/buckets/{bucket}/cors`. Read the current rules with `GET /v1/buckets/{bucket}/cors`. Do this *before* testing a browser upload — a missing rule looks exactly like a broken signature from the browser console.
- You’ll see
- 403 AccessDenied when a browser submits a multipart/form-data POST to the bucket root, using a policy document and signature as form fields.
- Why
- The edge parses inbound authentication from the Authorization header and the query string. It never looks at form fields, so a POST policy carries no identity it can verify.
- Instead
- Use a presigned PUT — it is one URL, it supports Content-Type and size limits through the signature, and every modern uploader supports it. For large browser uploads use presigned multipart via `POST /v1/files/multipart` and `POST /v1/files/multipart/{upload_id}/sign`.
How this page is kept true
Every snippet above was extracted from examples/browser/presigned-upload, a program that uploads, downloads, compares bytes, lists, presigns and cleans up after itself. It runs in CI against a live sandbox organisation via make docs-verify-node. If it stops passing, this page is wrong and we treat that as a bug in the product.
Not yet run against the live sandbox — the runner is wired, the first verified run stamps this line.