Spaces:
Runtime error
Runtime error
AzureAD\AdityaDevarshi
backend: skip logging client-canceled requests + rich dairy demo seed
25e051f | package api | |
| import ( | |
| "context" | |
| "crypto/rand" | |
| "encoding/hex" | |
| "encoding/json" | |
| "errors" | |
| "log" | |
| "net/http" | |
| "strconv" | |
| "strings" | |
| "time" | |
| "milkway/internal/cache" | |
| "milkway/internal/config" | |
| "milkway/internal/models" | |
| "milkway/internal/ratelimit" | |
| "milkway/internal/store" | |
| ) | |
| // maxBodyBytes caps request body size to defend against oversized-payload DoS. | |
| const maxBodyBytes = 1 << 20 // 1 MiB | |
| // cacheMaxEntries bounds the in-memory read cache. Generous for the handful of | |
| // org-scoped entities/rates/analytics rollups we memoize. | |
| const cacheMaxEntries = 10000 | |
| // Server holds dependencies shared by all HTTP handlers. | |
| type Server struct { | |
| cfg *config.Config | |
| store *store.Store | |
| cache cache.Cache | |
| // Rate limiters, per client IP. They are nil when their configured limit is | |
| // 0 (disabled), and the middleware treats a nil limiter as a no-op so test | |
| // traffic (which constructs config.Config{} with zero values) is never | |
| // throttled. Both use a fixed 1-minute window (the config is "per minute"). | |
| rateLimiter ratelimit.Limiter // global cap across all /api/* requests | |
| loginLimiter ratelimit.Limiter // stricter cap on POST /api/auth/login | |
| } | |
| func NewServer(cfg *config.Config, st *store.Store) *Server { | |
| // The cache is constructed here (not injected) so NewServer's signature is | |
| // unchanged and a Redis-backed cache.Cache can later replace NewMemory. | |
| s := &Server{cfg: cfg, store: st, cache: cache.NewMemory(cacheMaxEntries)} | |
| // Only build limiters when configured (>0). Leaving them nil keeps the | |
| // middleware a no-op, which is exactly what the zero-value test config wants. | |
| if cfg.RateLimitRPM > 0 { | |
| s.rateLimiter = ratelimit.NewWindow(cfg.RateLimitRPM, time.Minute) | |
| } | |
| if cfg.LoginRateLimitRPM > 0 { | |
| s.loginLimiter = ratelimit.NewWindow(cfg.LoginRateLimitRPM, time.Minute) | |
| } | |
| return s | |
| } | |
| // ---------- error envelope ---------- | |
| // Error codes returned in the error envelope. Each maps to an HTTP status. | |
| const ( | |
| CodeValidation = "VALIDATION" | |
| CodeUnauthenticated = "UNAUTHENTICATED" | |
| CodeForbidden = "FORBIDDEN" | |
| CodeNotFound = "NOT_FOUND" | |
| CodeConflict = "CONFLICT" | |
| CodeRateLimited = "RATE_LIMITED" | |
| CodeInternal = "INTERNAL" | |
| ) | |
| // apiError is the body of every error response, wrapped as {"error": apiError}. | |
| // Field names a specific input that failed validation; Ref correlates a 500 to | |
| // a server-side log entry and is only set for INTERNAL errors. | |
| type apiError struct { | |
| Code string `json:"code"` | |
| Message string `json:"message"` | |
| Field string `json:"field,omitempty"` | |
| Ref string `json:"ref,omitempty"` | |
| } | |
| // codeForStatus derives the error code from the HTTP status. | |
| func codeForStatus(status int) string { | |
| switch status { | |
| case http.StatusBadRequest: | |
| return CodeValidation | |
| case http.StatusUnauthorized: | |
| return CodeUnauthenticated | |
| case http.StatusForbidden: | |
| return CodeForbidden | |
| case http.StatusNotFound: | |
| return CodeNotFound | |
| case http.StatusConflict: | |
| return CodeConflict | |
| case http.StatusTooManyRequests: | |
| return CodeRateLimited | |
| default: | |
| return CodeInternal | |
| } | |
| } | |
| // ---------- response helpers ---------- | |
| 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 { | |
| _ = json.NewEncoder(w).Encode(v) | |
| } | |
| } | |
| // writeError emits the error envelope, deriving the code from the HTTP status. | |
| func writeError(w http.ResponseWriter, status int, msg string) { | |
| writeJSON(w, status, map[string]apiError{"error": { | |
| Code: codeForStatus(status), | |
| Message: msg, | |
| }}) | |
| } | |
| // writeFieldError emits the error envelope naming a specific input field. | |
| func writeFieldError(w http.ResponseWriter, status int, code, message, field string) { | |
| writeJSON(w, status, map[string]apiError{"error": { | |
| Code: code, | |
| Message: message, | |
| Field: field, | |
| }}) | |
| } | |
| // internalError logs the real error server-side with a short correlation ref and | |
| // returns a generic message to the client, so internal/DB details never leak. | |
| func internalError(w http.ResponseWriter, err error) { | |
| // A canceled request context means the client hung up before we finished — | |
| // e.g. the browser (or TanStack Query) aborted an in-flight fetch on navigation. | |
| // That is not a server fault: don't log it as one, and don't bother writing a | |
| // 500 nobody will read. (Deadline-exceeded is a real timeout and still logged.) | |
| if errors.Is(err, context.Canceled) { | |
| return | |
| } | |
| buf := make([]byte, 4) | |
| _, _ = rand.Read(buf) | |
| ref := hex.EncodeToString(buf) | |
| log.Printf("internal error [ref=%s]: %v", ref, err) | |
| writeJSON(w, http.StatusInternalServerError, map[string]apiError{"error": { | |
| Code: CodeInternal, | |
| Message: "internal error", | |
| Ref: ref, | |
| }}) | |
| } | |
| // handleStoreErr maps store errors to HTTP responses. Returns true if handled. | |
| // ErrNotFound becomes a 404; any other error is logged server-side and surfaced | |
| // to the client only as a generic message (no raw DB strings). | |
| func handleStoreErr(w http.ResponseWriter, err error) bool { | |
| if err == nil { | |
| return false | |
| } | |
| if errors.Is(err, store.ErrNotFound) { | |
| writeError(w, http.StatusNotFound, "not found") | |
| return true | |
| } | |
| internalError(w, err) | |
| return true | |
| } | |
| // decodeJSON reads a size-capped JSON body into dst. On any failure it returns a | |
| // generic message (parser internals are not leaked to the client). | |
| func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { | |
| r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) | |
| if err := json.NewDecoder(r.Body).Decode(dst); err != nil { | |
| writeError(w, http.StatusBadRequest, "invalid request body") | |
| return false | |
| } | |
| return true | |
| } | |
| // pathID parses the {id} path value as int64. | |
| func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { | |
| id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) | |
| if err != nil { | |
| writeError(w, http.StatusBadRequest, "invalid id") | |
| return 0, false | |
| } | |
| return id, true | |
| } | |
| func queryInt(r *http.Request, key string, fallback int) int { | |
| return config.MustAtoi(r.URL.Query().Get(key), fallback) | |
| } | |
| func queryInt64(r *http.Request, key string) int64 { | |
| v, _ := strconv.ParseInt(r.URL.Query().Get(key), 10, 64) | |
| return v | |
| } | |
| // pageParams parses the shared pagination/search query params for list | |
| // endpoints: ?q= (trimmed text search, optional), ?limit= (default 50, clamped | |
| // to 1..200) and ?offset= (default 0, floored at 0). | |
| func pageParams(r *http.Request) (q string, limit, offset int) { | |
| q = strings.TrimSpace(r.URL.Query().Get("q")) | |
| limit = queryInt(r, "limit", 50) | |
| if limit < 1 { | |
| limit = 1 | |
| } | |
| if limit > 200 { | |
| limit = 200 | |
| } | |
| offset = queryInt(r, "offset", 0) | |
| if offset < 0 { | |
| offset = 0 | |
| } | |
| return q, limit, offset | |
| } | |
| // audit records a mutating action against the authenticated user/org. It is | |
| // best-effort: a logging failure never affects the client response. entityID 0 | |
| // is recorded as an empty entity_id. | |
| func (s *Server) audit(r *http.Request, action, entity string, entityID int64) { | |
| c, ok := claimsFrom(r) | |
| if !ok { | |
| return | |
| } | |
| a := models.AuditLog{ | |
| OrgID: c.OrgID, | |
| UserID: &c.UserID, | |
| Action: action, | |
| Entity: entity, | |
| } | |
| if entityID != 0 { | |
| a.EntityID = strconv.FormatInt(entityID, 10) | |
| } | |
| _ = s.store.InsertAuditLog(r.Context(), a) | |
| } | |