Dev Skills

Step 4 — Serving images, and what io gives you

Send the file back safely, and see why one-method interfaces make everything fit together.

Topic 78 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Build a download handler that serves files safely with nosniff.
  • Explain what io.Reader and io.Writer are and why one-method interfaces compose.
  • Explain when local disk storage stops being enough.

The download handler

internal/adapter/handler/http/image.go — add this
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
img, err := h.svc.Get(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", img.MIME)
w.Header().Set("X-Content-Type-Options", "nosniff") // do not guess the type
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
// Names are random and never reused, so this is safe to cache forever.
http.ServeFile(w, r, filepath.Join(h.uploadDir, img.StoredName))
}
http.ServeFile handles range requests, If-Modified-Since and Content-Length for you. Do not write those by hand.

Why nosniff matters here

Some browsers will ignore the Content-Type you send and guess from the file contents instead. If somebody sneaks past your upload checks, that guessing is what turns a stored file into running code on your domain.

X-Content-Type-Options: nosniff tells the browser to believe your header and nothing else. One line closes that route. It does not help if the type you send is itself dangerous, such as text/html or image/svg+xml, which is why the upload allow-list matters too.

What io.Reader and io.Writer actually are

These two interfaces are much of why the Go standard library fits together. Each has one method:

the standard library
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}

A file, a network connection, an HTTP request body, a buffer in memory, a gzip stream, a hash and os.Stdout are all one or both of these. So any function written against them works with all of them, and you can stack them.

stacking them
// move bytes between ANY reader and ANY writer
io.Copy(dst, src)
// hash a file while you write it, at no extra read
h := sha256.New()
tee := io.TeeReader(file, h)
io.Copy(out, tee)
sum := h.Sum(nil)
// a writer wrapping a writer: encode → gzip → HTTP response
gz := gzip.NewWriter(w)
defer gz.Close()
json.NewEncoder(gz).Encode(data)
io.LimitReader(r, 1<<20) // a reader that stops after 1MB
io.MultiWriter(f, os.Stdout) // write to a file and the terminal at once
Because the interfaces are this small, almost any type can satisfy them, and wrappers such as gzip or a hash slot in between without either end knowing.

Local disk is fine, until it is not

Storing files on local disk is the right first choice: no credentials, no network, easy to debug. It stops working once you run more than one instance (instance B cannot serve a file that landed on instance A), or when your container has no disk that survives a restart.

The fix is not to redesign now. Keep the storage calls behind the Storage port, with Save, Open and Remove. Swapping local disk for S3 then means writing a second adapter, which is when an interface earns its place.

internal/core/port/storage.go — the shape to aim for
// Storage is declared by the code that uses it, so it is exactly this small.
type Storage interface {
Save(name string, r io.Reader) (int64, error)
Open(name string) (io.ReadCloser, error)
Remove(name string) error
}
// DiskStorage is the only implementation today. An S3 one would be a second
// type with the same three methods, and nothing else would change.
type DiskStorage struct{ dir string }

Check it

terminal
ID=$(curl -s -X POST localhost:8080/v1/products/1/image \
-H "Authorization: Bearer $ADMIN" -F "file=@photo.jpg" | jq -r .id)
curl -si localhost:8080/v1/images/$ID | head -5
# Content-Type: image/jpeg
# X-Content-Type-Options: nosniff
# Cache-Control: public, max-age=31536000, immutable
# and it works in a browser
open http://localhost:8080/v1/images/$ID
The short version
  • http.ServeFile handles ranges and caching. Set nosniff so the browser believes your Content-Type.
  • Random, never-reused filenames mean images can be cached forever.
  • io.Reader and io.Writer have one method each, which is why so much of the standard library composes.
  • Keep storage behind three methods so S3 later is a second implementation, not a refactor.
Remember: One-method interfaces are the reason files, sockets, buffers and HTTP bodies are interchangeable. It is one of the most useful design lessons in the standard library.
Build this before moving on
Serve images with the right headers, then load one in a real browser. Then write the Storage interface and move your disk code behind it.
Then try these in your own editor
  1. 1Remove nosniff, upload an HTML file renamed to .png, and see what the browser does. Then put it back.
  2. 2Add gzip to a JSON endpoint with gzip.NewWriter and compare response sizes.
  3. 3Write a second Storage implementation that keeps files in a map in memory, and use it in tests.
  4. 4Use io.LimitReader to cap a download at 1MB and see where it cuts off.
Official documentation
Check yourselfTake the Project 3 — Small shop test