Direct-from-browser upload
Goal: let a signed-in user upload a file from their browser directly to K3 — no bytes flowing through your application server.
Why: your server stays stateless, the upload runs at the network’s full bandwidth, and you don’t pay for inbound bandwidth twice.
Shape:
[browser] --(1) GET /api/upload-url--------------> [your backend]
[browser] <--(2) { url, key, expiresAt }--------- [your backend]
[browser] --(3) PUT <url> + file body-----------> [k3 gateway]The presigned URL is single-use, time-limited, and scoped to one bucket+key+method. Your backend never sees the file.
1. Configure CORS on the bucket
The browser will issue a cross-origin PUT and read response headers (etag). The bucket needs a CORS rule that allows it. Do this once per bucket:
cat > cors.json <<'EOF'
{
"corsRules": [
{
"allowedOrigins": ["https://app.example.com"],
"allowedMethods": ["PUT", "GET", "HEAD"],
"allowedHeaders": ["*"],
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3600
}
]
}
EOF
dodil k3 bucket cors set kb-prod -f cors.jsonAdjust allowedOrigins to match your frontend origin. Use ["*"] only for local development. See CORS — API Reference for the full shape.
2. Mint a presigned PUT URL on the backend
You have two paths, both supported by K3:
| When to use | |
|---|---|
| SigV4 presign via the S3 SDK | You already have S3 SDK creds; works with every S3 client |
K3 presign via GetObjectUrl | Shorter URLs, but read-only — won’t work for upload (PUT) |
For uploads you need SigV4 presign, because GetObjectUrl issues GET-only URLs. Implementation:
Node (@aws-sdk)
// backend route: GET /api/upload-url?filename=foo.pdf
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
const s3 = new S3Client({
endpoint: process.env.K3_ENDPOINT, // https://k3.dev.dodil.io
region: "us-east-1",
forcePathStyle: true,
credentials: {
accessKeyId: process.env.DODIL_SA_ID!,
secretAccessKey: process.env.DODIL_SA_SECRET!,
},
});
export async function uploadUrlHandler(req, res) {
const { filename, contentType } = req.query;
// Namespace under the user, never trust the raw filename
const key = `uploads/${req.user.id}/${randomUUID()}/${filename}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: "kb-prod",
Key: key,
ContentType: contentType,
}),
{ expiresIn: 300 } // 5 minutes — short-lived
);
res.json({ url, key, expiresAt: Date.now() + 300_000 });
}3. Upload from the browser
<input id="file" type="file" />
<button id="upload">Upload</button>
<script type="module">
document.getElementById("upload").onclick = async () => {
const file = document.getElementById("file").files[0];
// 1. Ask backend for a presigned URL
const r = await fetch(
`/api/upload-url?filename=${encodeURIComponent(file.name)}` +
`&contentType=${encodeURIComponent(file.type)}`
);
const { url, key } = await r.json();
// 2. PUT directly to K3 — no server in the path
const put = await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
// 3. Tell your backend the upload finished so it can record metadata
await fetch("/api/upload-complete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ key, etag: put.headers.get("etag") }),
});
};
</script>Showing upload progress
fetch can’t report PUT progress — use XMLHttpRequest instead:
function uploadWithProgress(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
xhr.onload = () => (xhr.status === 200 ? resolve(xhr) : reject(xhr));
xhr.onerror = () => reject(xhr);
xhr.send(file);
});
}What happens after a successful upload
Once the PUT returns 200, K3’s S3 gateway:
- Persists the object to durable storage.
- Fires ingest discovery — if
kb-prodhas matching pipeline rules, the object is automatically routed to vector / warehouse / Scriptum pipelines (see Pipelines). You can checkObjectInfo.pipelineStatusesfor per-rule progress. - Returns the
ETagheader — the SHA-1 / MD5 of the object body, useful for client-side integrity checks.
Your frontend doesn’t need to wait for ingest to complete — pipelines run async and your code can poll GetObjectInfo if it needs to know when indexing finishes.
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Browser blocks PUT with CORS error | Bucket has no CORS, or origin doesn’t match | Set CORS as in step 1; verify with dodil k3 bucket cors get kb-prod |
403 SignatureDoesNotMatch | Frontend sets headers the URL wasn’t signed for | Only set Content-Type and only if it was in the PutObjectCommand params |
403 AccessDenied from quota | Org’s storage entitlement is full | Check your org’s quota or contact admin |
| URL expires before upload finishes | expiresIn too short for large files | Use multipart for files > 100 MB — each part has its own URL |
ETag header missing in response | Browser CORS strips response headers by default | Include "ETag" in exposeHeaders of the CORS rule (already done in step 1) |
Security notes
- Always namespace keys server-side (e.g.
uploads/<userId>/<uuid>/...). Never let the client choose the key — it’s a path-traversal risk. - Keep
expiresInshort (60–300 s). The URL is a bearer token for one PUT. - Optionally bind to content type / size — pass
ContentType(and considerContentLengthenforcement at presign time) so a client can’t lie about what they’re uploading. - Don’t rely on
Content-Typethe browser sends for security decisions — sniff server-side if you care.
See also
- Multipart Large Files — for files over ~100 MB
- CORS — API Reference — full CORS rule shape
- S3 Compatibility — SDK setup details
- Pipelines — what happens to the object after upload