Download nightly exports from the web app
Go · Go · intermediate · modification
Lets admins download the nightly CSV exports straight from the Exports page instead of asking support for them. The file name is validated against the same character set the export job uses (and capped at 100 characters) before it touches the filesystem, and every lookup is rooted in the caller's own org directory. `http.ServeContent` gives us range requests for free, and the response is marked `private, no-store` because the files hold payroll data. Tested locally with the September export, a missing file (404) and a `.part` file (400).
Go 1.22 service in Linux containers, standard library only (`net/http` method-pattern `ServeMux`, `log/slog`). `auth.FromContext(ctx)` returns the signed-in `auth.Member` (`OrgID int64`). The nightly export job writes `<ExportDir>/<orgID>/<name>.csv`, where org ids are sequential integers that appear in the app's URLs. Names are like `payroll-2026-09.csv`, and each file is written as `<name>.csv.part` and renamed into place when complete. The export volume holds only those org directories and regular files, with no symlinks, and is mounted read-only into this service. Exports contain employees' salaries and bank details. `GET /api/exports` (existing) lists the caller's org's file names; `writeJSON(w http.ResponseWriter, status int, v any)` (existing, same package) sets the JSON content type, writes the status and encodes `v`.
Requirements
- `GET /api/exports/download?file=<name>` sends one of the caller's org's export files. `name` must be a bare file name of the kind `GET /api/exports` lists: only the characters `A-Z a-z 0-9 . _ -`, not starting with `.`, ending in `.csv`, at most 100 characters. Any other value answers 400 `{"error": "invalid_file"}`.
- An org's exports live in `<ExportDir>/<orgID>/`, and a user can only ever download their own org's files. A valid name with no regular file behind it answers 404 `{"error": "not_found"}`; any other error opening or inspecting the file is logged and answers 500 `{"error": "internal"}`.
- Once the file is open, `http.ServeContent` sends it, and its handling of ranges, conditional requests and read errors is fine as it is. The response carries `Content-Type: text/csv; charset=utf-8`, `Content-Disposition: attachment` with the file name, and `Cache-Control: private, no-store`.
- Signed-in org members only: the mux already sits behind the auth middleware.
Files touched
- internal/exports/routes.go
- internal/exports/download.go
--- internal/exports/routes.go
h := &Handler{ExportDir: exportDir}
mux.HandleFunc("GET /api/exports", h.List)
+ mux.HandleFunc("GET /api/exports/download", h.Download)
}
--- internal/exports/download.go
+package exports
+
+import (
+ "errors"
+ "io/fs"
+ "log/slog"
+ "mime"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+
+ "example.com/payroll/internal/auth"
+)
+
+const maxNameLength = 100
+
+var exportName = regexp.MustCompile(`[A-Za-z0-9_-][A-Za-z0-9._-]*\.csv$`)
+
+func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
+ member, ok := auth.FromContext(r.Context())
+ if !ok {