File storage

S3-compatible: R2, MinIO, B2.

7 minmedium

File uploads — avatars, attachments, exports. Grit's storage package abstracts S3-compatible backends: MinIO in dev (ships with docker-compose), Cloudflare R2 or AWS S3 in production. One interface, swap the backend via env.

The Storage interface

apps/api/internal/storage/storage.go
type Storage interface {
Put(ctx context.Context, key string, body io.Reader, contentType string) (string, error)
Get(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
PresignedURL(ctx context.Context, key string, expires time.Duration) (string, error)
}

Same four operations regardless of backend. The handler doesn't know if it's talking to MinIO or R2.

Switching backends

.env
# Choose backend
STORAGE_DRIVER=minio # minio | r2 | s3
# MinIO (dev — bundled in docker-compose)
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=bench-api-uploads
# Cloudflare R2 (prod)
R2_ACCOUNT_ID=...
R2_ACCESS_KEY=...
R2_SECRET_KEY=...
R2_BUCKET=acme-prod-uploads

Change STORAGE_DRIVER, restart. Same handler code, different bucket.

The Upload handler

Grit ships a generic upload handler at POST /api/uploads:

POST /api/uploads
Headers: Authorization: Bearer <token>
Body: multipart/form-data with file=<file>
Response:
{ "data": { "id": "9b...", "url": "/api/uploads/9b...", "size": 24832 } }

Behind the scenes: file goes into the storage backend keyed by its SHA-256 hash (deduplication by content), an Upload row is created in the DB. The returned URL is a Grit proxy URL that streams the file with auth + access checks.

Direct uploads (presigned URLs) — when files get large

For files >5 MB, you don't want them to pass through your API process. Use presigned URLs:

// API generates a one-time URL the client uploads directly to
url, err := h.storage.PresignedURL(ctx, key, 15*time.Minute)
// Return url to the client; they PUT the file directly to S3/R2

Bandwidth doesn't hit your API. Useful for image uploads, video, backups, exports.

Don't accept user URLs to fetch (SSRF)! A common bug: "upload from URL" lets users send a URL and your server fetches it. Without filtering, attackers can target your AWS metadata endpoint or internal services. Use internal/safefetch.Get(url) instead — it blocks loopback, RFC1918, and cloud-metadata IPs. The Defender's Handbook page covers SSRF in depth.

Content type validation

The handler validates content-type from the uploaded bytes (sniffing), not from the user-supplied Content-Type header. An attacker who renames shell.php to image.png gets caught — the sniffer sees PHP source, the handler rejects.

Image processing

For avatars and thumbnails, Grit ships an image processing pipeline:

processed, err := h.imageProcessor.Resize(ctx, body, 256, 256)
// Outputs JPEG, 256x256, ~20 KB
storage.Put(ctx, key, processed, "image/jpeg")

Quick check

A user submits a 50 MB video to /api/uploads. What's the right pattern?

Try it

Upload a real file and read it back:

  1. With your access token, POST a file (a small PNG) to /api/uploads with curl:
    Terminal
    $curl -X POST http://localhost:8080/api/uploads \
    $ -H "Authorization: Bearer YOUR_TOKEN" \
    $ -F "file=@avatar.png"
  2. Note the url in the response.
  3. Open MinIO console (http://localhost:9001, login minioadmin/minioadmin) — you should see the file in the bucket.
  4. Fetch the file via the API URL with your token — should stream the PNG back.

What's next

Last battery — AI Gateway. Stream from Claude, GPT-4, and ~98 other models with one API key. Built for products that wrap AI as a feature.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub