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) }