File Uploads
File Uploads
What it does
Presigned, direct-to-S3 uploads/downloads for three use cases: your own avatar,
an organization's logo, and a shared shelf of private "org documents" any
member can upload to. No file ever passes through the Next.js server — the
browser PUTs straight to S3 (or a local MinIO container), and every download
goes through a stable /api/files/{id} URL that redirects to a freshly-minted,
short-lived presigned GET.
How to configure it
| Var | Purpose |
|---|---|
S3_ENDPOINT | Custom S3-compatible endpoint (MinIO, R2, ...). Leave unset against real AWS S3. |
S3_REGION | Defaults to us-east-1 if unset. |
S3_BUCKET | Bucket name. |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY | Credentials. |
All absent by default — the upload widgets simply don't render ("File uploads
aren't configured" instead), everything else works unaffected. docker-compose.yml
runs a local MinIO (minio service, console at http://localhost:9001,
minioadmin/minioadmin) plus a one-shot minio-init container that creates
the bucket on first npm run dev:db; .env.example's S3_* defaults already
point at it.
Deploying to a real S3 bucket: drop S3_ENDPOINT, set the other four vars
to your real bucket/credentials, and configure the bucket's own CORS policy to
allow PUT/GET from your app's origin (MinIO's MINIO_API_CORS_ALLOW_ORIGIN
env var does the same thing for local dev — see docker-compose.yml). If you
flip next.config.ts's CSP from report-only to enforcing (see docs/security.md),
add your real bucket's domain to img-src/connect-src by hand — it isn't
derivable from env the way a custom S3_ENDPOINT origin is (see that file's
comment).
How it works
lib/storage.ts— thin wrapper around@aws-sdk/client-s3+@aws-sdk/s3-request-presigner.isStorageConfigured()gates every upload UI and server action;presignPutUrl()/presignGetUrl()mint short-lived URLs (5 min / 60s),deleteObject()removes an object.forcePathStyleis set automatically wheneverS3_ENDPOINTis set — required for MinIO, which doesn't support virtual-hosted-stylebucket.host/keyURLs.filestable (lib/db/schema/app.ts) — one row per object:ownerId, optionalorganizationId,kind(avatar|org_logo|org_document),key,name,size,mime.kinddrives access control (see below).- The upload flow is always two steps, both server actions in
lib/actions/uploads.ts:request*Upload(fileName, fileType, fileSize)— validates against an allowlist (IMAGE_MIME_TYPES/DOCUMENT_MIME_TYPES, 5MB/20MB caps inlib/storage.ts), pre-inserts thefilesrow, returns a presigned PUT URL + the row's id. The client (components/settings/image-upload.tsx,components/organizations/org-documents.tsx)fetches a plainPUTstraight to that URL with the raw file body.confirm*Upload(fileId)— called once the PUT succeeds. For an avatar, setsuser.image(viaauth.api.updateUser) to/api/files/{fileId}and deletes whatever avatar it replaced (row + S3 object — seecleanupPreviousImage). Org documents don't have a confirm step; the pre-inserted row is the confirmation (any member can add one, so there's no "assign as the current X" step to gate).
requireOrgRole()duplicates better-auth's own organization role check (owner/admin only for logo changes) because presigning an upload isn't a better-auth endpoint — nothing else in the request path checks it. It queries themembertable directly rather than callingauth.api.getFullOrganization(), which throws a 403APIErrorfor a non-member caller instead of returningnull(it assumes the caller is already established to be allowed to ask) — that would otherwise surface as an uncaught 500 for exactly the "you're not allowed" case this function exists to handle cleanly.
app/api/files/[id]/route.ts— the only place a stored file is ever actually read from. Looks up the row, and forkind: "org_document"requires the requester to be a member oforganizationIdor a site admin (same directmember-table query, same reasoning as above) before minting a presigned GET and307-redirecting to it.avatar/org_logoare intentionally public — no session check — since they render in plain<img>tags with no guaranteed session context (sidebar, org switcher).user.image/organization.logostore this stable app URL, never a raw S3 one, so nothing that renders them needs to know a URL could go stale.- UI:
components/settings/image-upload.tsxis the shared avatar/logo widget (app/(app)/settings/page.tsx→ProfileForm;components/organizations/org-profile-form.tsx, owner/admin only).components/organizations/org-documents.tsxis the shared org-documents shelf on/settings/organization— any member can upload, only the uploader or an owner/admin can delete (deleteOrgDocument).app/(admin)/admin/files/page.tsxlists every file across every user/org with a working download link (works for any row because the site-admin bypass in the route above covers it).
How to extend it
- A new upload use case — add a
kindto thefilestable's enum, request/confirm actions inlib/actions/uploads.tsfollowing the existing pair as a template, and an access-control branch inapp/api/files/[id]/route.tsif it needs to be private. - Per-type size/mime limits —
lib/storage.ts'sIMAGE_MIME_TYPES/DOCUMENT_MIME_TYPES/MAX_IMAGE_BYTES/MAX_DOCUMENT_BYTES; add a third pair rather than widening the existing ones if a new use case needs different limits. - Virus scanning / image processing — hook into
confirm*Upload(the object already exists in S3 at that point) or move to a queued job triggered by an S3 event notification if you need it off the request path.
How to remove it
Delete lib/storage.ts, lib/actions/uploads.ts,
components/settings/image-upload.tsx,
components/organizations/org-documents.tsx, app/api/files/,
app/(admin)/admin/files/, the files table (regenerate a migration), the
minio/minio-init services from docker-compose.yml, the S3_* vars, the
avatar/logo upload wiring from components/settings/profile-form.tsx +
components/organizations/org-profile-form.tsx (back to plain initials), the
sidebar avatar in app/(app)/layout.tsx, the "Files" admin nav link, and the
img-src/connect-src S3-origin logic in next.config.ts.