Step 4 — Serving images, and what io gives you
Send the file back safely, and see why one-method interfaces make everything fit together.
- 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
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))}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:
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.
// move bytes between ANY reader and ANY writerio.Copy(dst, src)// hash a file while you write it, at no extra readh := sha256.New()tee := io.TeeReader(file, h)io.Copy(out, tee)sum := h.Sum(nil)// a writer wrapping a writer: encode → gzip → HTTP responsegz := gzip.NewWriter(w)defer gz.Close()json.NewEncoder(gz).Encode(data)io.LimitReader(r, 1<<20) // a reader that stops after 1MBio.MultiWriter(f, os.Stdout) // write to a file and the terminal at onceLocal 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.
// 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
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 browseropen http://localhost:8080/v1/images/$IDhttp.ServeFilehandles ranges and caching. Setnosniffso the browser believes your Content-Type.- Random, never-reused filenames mean images can be cached forever.
io.Readerandio.Writerhave 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.
Storage interface and move your disk code behind it.- 1Remove
nosniff, upload an HTML file renamed to.png, and see what the browser does. Then put it back. - 2Add gzip to a JSON endpoint with
gzip.NewWriterand compare response sizes. - 3Write a second
Storageimplementation that keeps files in a map in memory, and use it in tests. - 4Use
io.LimitReaderto cap a download at 1MB and see where it cuts off.