Streaming hash and the storage interface
Auth is done; now the app gains its subject: documents. This lesson is upload — and it lands two ideas at once: a storage layer behind an interface (the same seam you just built for email) and a file that is hashed as it is written, never buffered whole in memory.
Storage behind an interface — the seam, a second time. Where the bytes live is a decision you want to change later: dev writes to ./uploads, prod wants an object store (S3, R2, a Railway volume). So storage sits behind a three-method interface, exactly as email did:
type Store interface {
Save(ctx context.Context, r io.Reader) (key string, size int64, sha256hex string, err error)
Open(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
}
LocalStore is the dev implementation. Swapping in an S3Store later is a constructor change in main, not a rewrite — "in production this is an object store; here it is a folder on disk." You saw this shape with email.Sender; seeing it twice is the point — it is how you keep an infrastructure choice from leaking into every handler.
Hash while you write — one pass, never buffered. The naive upload reads the whole file into a []byte, hashes that, then writes it. A 25 MiB file becomes 25 MiB of RAM per concurrent upload — and it falls over the moment two big files arrive together. The fix is io.MultiWriter:
h := sha256.New()
size, err := io.Copy(io.MultiWriter(f, h), r) // one read; bytes fan out to file AND hasher
Every byte read from the request is written to the file and fed to the hasher in the same pass. Memory stays flat no matter how big the file is, and when io.Copy returns you have both the size and the SHA-256 — for free, having read the bytes exactly once. That hash goes on the document row; it is what later lets a signature pin the exact bytes that were signed.
The storage key is not the row id. The store generates its own key — 16 random bytes as 32 hex chars — decoupled from the document's UUID. And every Open/Delete validates that key against a regex before touching the disk:
var keyPattern = regexp.MustCompile(`^[a-f0-9]{32}$`)
A storage key comes out of the database and gets joined onto a directory path. If a key were ever ../../etc/passwd, filepath.Join would happily walk out of the upload folder. Validating every key against ^[a-f0-9]{32}$ before use means a key can only ever name a file inside the base dir — the traversal is impossible by construction, not by hoping the data is clean.
A hard size cap. http.MaxBytesReader wraps the request body so a client cannot stream an unbounded file and exhaust the disk; anything over 25 MiB is cut off and the handler returns 413. The limit lives in config (MaxUploadBytes), read once.
HTMX makes the upload feel live. The upload form posts with hx-post + hx-encoding="multipart/form-data"; the response is just the refreshed document-list partial, swapped in place — no full-page reload. A <progress> element wired to htmx's xhr:progress events shows a real upload bar. This is the track's central argument made concrete: a server-rendered app, no SPA, yet the interaction feels immediate.
This lesson:
- The
storage.Storeinterface +LocalStore, thedocumentstable and its two queries, and the config/wiring. - The upload handler and the dashboard that lists what you have uploaded — end to end, with a live progress bar.