Vellum/internal/httpx/json.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

90 lines
2.5 KiB
Go

// Package httpx holds small HTTP helpers shared by feature packages without
// creating an import cycle (feature modules import httpx; httpx imports
// nothing of ours).
package httpx
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)
// maxRequestBody caps decoded JSON bodies to protect against memory abuse.
const maxRequestBody = 1 << 20 // 1 MiB
// WriteJSON encodes v as JSON with the given status.
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if v == nil {
return
}
_ = json.NewEncoder(w).Encode(v)
}
// WriteError writes a JSON error envelope.
func WriteError(w http.ResponseWriter, status int, message string) {
WriteJSON(w, status, map[string]string{"error": message})
}
// DecodeJSON reads a JSON request body into dst.
//
// It rejects unknown fields and non-object top-level values, and refuses
// bodies larger than 1 MiB. Error messages are safe to return to clients.
func DecodeJSON(r *http.Request, dst any) error {
if ct := r.Header.Get("Content-Type"); ct != "" {
if mediaType := contentTypeOnly(ct); mediaType != "application/json" {
return fmt.Errorf("expected Content-Type application/json, got %q", ct)
}
}
dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBody))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxErr):
return fmt.Errorf("malformed JSON at byte %d", syntaxErr.Offset)
case errors.As(err, &typeErr):
return fmt.Errorf("field %q has the wrong type", typeErr.Field)
case errors.Is(err, io.EOF):
return errors.New("request body is empty")
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("request body is truncated")
default:
return err
}
}
// Reject trailing garbage after the first JSON value.
if dec.More() {
return errors.New("request body contains more than one JSON value")
}
return nil
}
// contentTypeOnly strips parameters from a Content-Type header, e.g.
// "application/json; charset=utf-8" -> "application/json".
func contentTypeOnly(ct string) string {
for i := 0; i < len(ct); i++ {
if ct[i] == ';' {
return trimSpace(ct[:i])
}
}
return trimSpace(ct)
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}