Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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

VarPurpose
S3_ENDPOINTCustom S3-compatible endpoint (MinIO, R2, ...). Leave unset against real AWS S3.
S3_REGIONDefaults to us-east-1 if unset.
S3_BUCKETBucket name.
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEYCredentials.

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. forcePathStyle is set automatically whenever S3_ENDPOINT is set — required for MinIO, which doesn't support virtual-hosted-style bucket.host/key URLs.
  • files table (lib/db/schema/app.ts) — one row per object: ownerId, optional organizationId, kind (avatar | org_logo | org_document), key, name, size, mime. kind drives access control (see below).
  • The upload flow is always two steps, both server actions in lib/actions/uploads.ts:
    1. request*Upload(fileName, fileType, fileSize) — validates against an allowlist (IMAGE_MIME_TYPES/DOCUMENT_MIME_TYPES, 5MB/20MB caps in lib/storage.ts), pre-inserts the files row, returns a presigned PUT URL + the row's id. The client (components/settings/image-upload.tsx, components/organizations/org-documents.tsx) fetches a plain PUT straight to that URL with the raw file body.
    2. confirm*Upload(fileId) — called once the PUT succeeds. For an avatar, sets user.image (via auth.api.updateUser) to /api/files/{fileId} and deletes whatever avatar it replaced (row + S3 object — see cleanupPreviousImage). 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 the member table directly rather than calling auth.api.getFullOrganization(), which throws a 403 APIError for a non-member caller instead of returning null (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 for kind: "org_document" requires the requester to be a member of organizationId or a site admin (same direct member-table query, same reasoning as above) before minting a presigned GET and 307-redirecting to it. avatar/org_logo are intentionally public — no session check — since they render in plain <img> tags with no guaranteed session context (sidebar, org switcher). user.image/organization.logo store 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.tsx is the shared avatar/logo widget (app/(app)/settings/page.tsx → ProfileForm; components/organizations/org-profile-form.tsx, owner/admin only). components/organizations/org-documents.tsx is 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.tsx lists 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 kind to the files table's enum, request/confirm actions in lib/actions/uploads.ts following the existing pair as a template, and an access-control branch in app/api/files/[id]/route.ts if it needs to be private.
  • Per-type size/mime limits — lib/storage.ts's IMAGE_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.