Upload files with S3 or getqr.id
Add private avatar uploads to a Next.js app on S3 or getqr.id, test them offline, and swap stores by changing one line of main.yaml.
Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
You will add an avatar upload to Pinecart, a Next.js app on MainSDK: the browser sends the file straight to the store, files.complete() scans it, and only its owner can read it. You pick a store, write two routes and one browser function, test it offline, and finish with a passing mainsdk check. It takes about 20 minutes, and you can change the store later by editing one line.
You need
- A Next.js App Router app with a
main.yaml,authanddbset up (see Quickstart). - An account and secret for the store you pick: a getqr.id API key, or an S3 bucket and access key.
- Secrets stored as references in your secrets manager, never in code (see Secrets).
Pick a driver
files has one contract and several drivers. Every driver keeps the same guarantees (ADR-0013): owner-scoped reads, short-lived signed URLs, a scan before a file is readable, resumable uploads and an idempotency key. A driver that cannot meet one declares the gap, and mainsdk check names it.
| Driver | Pick it when | Private files | Notes |
|---|---|---|---|
getqr | You want the default, with uploads, share links and QR codes in one place. HubXero products use it. | Owner-scoped, signed URLs | Needs a getqr.id API key. getqr.id scans server side, and its upload target is a tus endpoint (PATCH), so the browser needs a tus-aware client such as <Upload/> from @getqr/react. Setup |
s3 | You already have a bucket, need its region, or want files in your own cloud account. Also R2, MinIO and Backblaze B2. | Owner-scoped, signed URLs. Keep Block all public access on. | Browser uploads need a CORS rule on the bucket. On AWS you can use the runtime's IAM role and set no keys. Setup |
uploadthing | Planned. | Not built yet (MAIN-FILES-0014). | Setup |
vercel-blob | Planned. | Not built yet (MAIN-FILES-0014). | Setup |
local | Development and tests, with no account. | Owner-scoped | Not for production. |
Sharing is separate from storage. share(), links and QR codes always use getqr.id, whichever driver holds the file (ADR-0009). See Share links and QR codes.
1. Declare the driver in main.yaml
Pick one block. Each key is a secret: reference, so no secret is in the file. A literal value fails with MAIN-CONFIG-0004. For S3, access-key-id is an identifier, not a secret, so it may be a literal.
files: { driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB }files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true }
# on AWS, omit access-key-id and key to use the runtime's IAM role
# R2, MinIO and B2 also set: endpoint: https://<their-endpoint>The S3 region is the bucket's own region: pick a bucket in the region your users' data must stay in. For S3, create the bucket with Block all public access on and add the CORS rule from Amazon S3, or the browser cannot upload.
You should see: npx mainsdk check passes, or names a missing secret.
2. Use the local driver in development
A dev overlay means you need no account to build. See main.yaml and overlays.
files:
driver: localYou should see: npx mainsdk dev -- npm run dev stores files on disk with no network call.
3. Issue an upload on the server
files.uploadUrl() returns an Upload: the URL, the HTTP method, the headers or form fields to send, and a pending FileRef in upload.ref. It is signed, single-use, limited in size and type, and expires in 15 minutes. The bytes never pass through your server.
import { auth, files } from "mainsdk";
export async function POST(req: Request) {
const session = await auth.require(req); // 401 with a fix if not signed in
const upload = await files.uploadUrl({
owner: session.user,
maxSize: "5MB",
types: ["image/png", "image/jpeg"],
});
return Response.json(upload); // { method, url, headers?, fields?, ref }
}You should see: a signed-in POST /api/uploads returns method, url and ref with ref.status set to "pending", plus headers (for PUT) or fields (for POST).
4. Upload from the browser
This is the one browser function for every driver. The other pages link here. For PUT it sends the bytes as the body with upload.headers. For POST it sends a form with upload.fields first and the file last.
import type { Upload } from "mainsdk/files";
export async function uploadFile(file: File) {
const res = await fetch("/api/uploads", { method: "POST" });
const upload = (await res.json()) as Upload;
if (upload.method === "PUT") {
await fetch(upload.url, { method: "PUT", headers: upload.headers, body: file });
} else {
const form = new FormData();
for (const [name, value] of Object.entries(upload.fields ?? {})) form.append(name, value as string);
form.append("file", file); // the file goes last in a form upload
await fetch(upload.url, { method: "POST", body: form });
}
const done = await fetch(`/api/uploads/${upload.ref.id}/complete`, { method: "POST" });
return (await done.json()) as { url: string };
}The store refuses a file over maxSize or outside types (MAIN-FILES-0006, MAIN-FILES-0007), and the signed URL works once (MAIN-FILES-0004 after that). With the getqr.id driver you can use <Upload/> from @getqr/react instead of this function (see File uploads).
You should see: a 6 MB file or a text/html file is refused by the store, and a small PNG goes through.
5. Complete the upload and read the file
files.complete() runs the scan and only then makes the file readable. files.url() returns a short-lived URL for its owner. Pass it upload.ref.id, which arrives here as the route's id.
import { auth, files } from "mainsdk";
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.require(req);
const { id } = await params;
const ref = await files.complete(id, { owner: session.user }); // only the uploader can finish it
const url = await files.url(ref, { for: session.user, expiresIn: "1h" });
return Response.json({ url });
}Save ref on your own record here if the file belongs to one, for example the user's avatar.
You should see: the response holds a URL that stops working after an hour. A reader that access denies gets MAIN-FILES-0002 with status 403. Another user who sends someone else's upload id to the complete route also gets MAIN-FILES-0002, and the upload stays pending. An infected file makes files.complete() fail with MAIN-FILES-0005: the file is quarantined and no URL exists.
6. Test it offline
createTestApp() puts files on its in-memory fake, with the same owner scope and scanning as production. testApp.files.put(upload, bytes, { type }) plays the browser's part, so the test covers the direct flow with no network. It covers a denied reader (files-share AC1) and the EICAR test file (files-share AC2).
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { files } from "mainsdk";
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const eicar = new TextEncoder().encode(
"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
);
describe("uploads", () => {
it("files-share AC1 refuses a reader access denies", async () => {
const testApp = createTestApp({ main: "./main.yaml" });
const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["image/png"] });
await testApp.files.put(upload, png, { type: "image/png" });
const ref = await files.complete(upload.ref.id, { owner: { id: "u1" } });
await assert.rejects(files.url(ref, { for: { id: "u2" } }), { code: "MAIN-FILES-0002" });
});
it("files-share AC2 refuses an infected file", async () => {
const testApp = createTestApp({ main: "./main.yaml" });
const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["text/plain"] });
await testApp.files.put(upload, eicar, { type: "text/plain" });
await assert.rejects(files.complete(upload.ref.id, { owner: { id: "u1" } }), { code: "MAIN-FILES-0005" });
});
});Use clock.advance("61m") to expire a one-hour signed URL without waiting.
Verify
npx mainsdk checkAdd --env prod to check the production overlay. A passing run looks like this:
$ npx mainsdk check
mainsdk check · notes · env dev · Main 27
config secrets policy network auth data
Passed · 6 gates · 0 findings · 1.2sImport @aws-sdk/client-s3, uploadthing or @vercel/blob in a route and the check fails with MAIN-FILES-0010 and names the file. Replace the import with files.upload() or files.uploadUrl().
If it fails
| Code | Cause | Fix |
|---|---|---|
MAIN-FILES-0002 | files.url() was called for a reader access denies (403). | Read as the owner, or add an access rule for the reader. |
MAIN-FILES-0005 | The scan in files.complete() found an infected file. The upload fails, the file is quarantined and no URL exists. | Ask the user for a clean file. Nothing to fix in code. |
MAIN-FILES-0010 | mainsdk check found a storage SDK imported in route code. | Use files.upload() or files.uploadUrl(), or set that store as the files driver. |
MAIN-CONFIG-0004 | key is a literal value, not a secret: reference. | Move the value to your secrets manager and reference it. |
MAIN-CHECK-0026 | files.driver is still local in production. | Set a real driver in main.yaml and keep local only in main.env.dev.yaml. |
Run npx mainsdk explain MAIN-FILES-0005 for the cause and fix offline.
Swap drivers later
Change driver and its keys. files.uploadUrl(), files.complete() and files.url() stay the same, and so do owner scope, scanning and audit.
files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true } # was getqrExisting files stay where they are until you migrate them, and old FileRef values keep pointing at the old store. How a migration moves them is not decided yet. See Swap a driver.
For agents
Goal: add owner-scoped avatar upload to this Next.js app with the files interface.
Read: docs/specs/files-share.md, the driver's integration page, main.yaml.
Touch only: main.yaml, main.env.dev.yaml, app/api/uploads/**, app/profile/upload-file.ts, one test file.
Do: declare files.driver with a secret: reference; add a dev overlay with driver local;
issue the upload with files.uploadUrl({ owner, maxSize, types }) in POST /api/uploads;
send the file to upload.url with upload.method (the browser function in the guide);
call files.complete(upload.ref.id, { owner }) as the same user before files.url(ref, { for, expiresIn }).
Never import @aws-sdk/client-s3, uploadthing or @vercel/blob in route code.
Never put a key in code. Never weaken, skip or loosen a check to make it pass.
Verify: npx mainsdk check --json --agent exits 0, and the test names its criterion (files-share AC1).Next
Open questions
Was this page helpful?