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.
90 lines
2.5 KiB
Go
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]
|
|
}
|