package session import ( "context" "log/slog" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "go.bigb.es/auxilia/culpa" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror" "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil" "sourcecraft.dev/bigbes/lethe/internal/server/auth" ) // Pagination knobs locked by the spec. The Handler clamps client-supplied // values into [0, defaultLimit] / [0, maxLimit] before reaching Repository. const ( defaultLimit = 50 maxLimit = 200 ) // allOwnersSentinel is the literal value of `?owner=` that an admin uses to // scope a list across every owner. Any other non-empty value is interpreted // as a SpecificOwner. const allOwnersSentinel = "*" // Handler is the steward-managed HTTP boundary for the sessions read API. // Repo is the injected SQL steward; the Handler holds no other state. type Handler struct { Repo *Repository `inject:""` } // Init satisfies the steward Initer contract. The Handler is stateless // beyond its injected dependencies. func (h *Handler) Init(_ context.Context) error { return nil } // Mount registers the two read routes under r. Server.Init mounts this // inside the /api/v1 group, so the effective paths are // `/api/v1/sessions` and `/api/v1/sessions/{tool}/{host}/{session_id}`. func (h *Handler) Mount(r chi.Router) { r.Get("/sessions", h.List) r.Get("/sessions/{tool}/{host}/{session_id}", h.Get) } // listResponse is the JSON body returned by List. The Limit/Offset echo back // the (possibly clamped) effective values so clients can detect that their // supplied limit was capped. type listResponse struct { Sessions []Session `json:"sessions"` Limit int `json:"limit"` Offset int `json:"offset"` } // List handles GET /sessions. It resolves the owner scope (admin gating on // `?owner=`), parses optional filters, clamps pagination, and writes a // listResponse. Errors surface through apierror.Render as RFC 7807. func (h *Handler) List(w http.ResponseWriter, r *http.Request) { scope, err := h.resolveScope(r) if err != nil { apierror.Render(w, r, err) return } q := r.URL.Query() filter := ListFilter{Owner: scope} if v := q.Get("tool"); v != "" { filter.Tool = &v } if v := q.Get("host"); v != "" { filter.Host = &v } if v := q.Get("since"); v != "" { n, perr := strconv.ParseInt(v, 10, 64) if perr != nil { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.Wrap(perr, "parse since"), "since must be an integer (unix epoch seconds)"), "INVALID", )) return } filter.Since = &n } if v := q.Get("until"); v != "" { n, perr := strconv.ParseInt(v, 10, 64) if perr != nil { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.Wrap(perr, "parse until"), "until must be an integer (unix epoch seconds)"), "INVALID", )) return } filter.Until = &n } if filter.Since != nil && filter.Until != nil && *filter.Since > *filter.Until { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.New("since > until"), "since must be <= until"), "INVALID", )) return } filter.Limit = clampLimit(q.Get("limit")) filter.Offset = clampOffset(q.Get("offset")) rows, err := h.Repo.List(r.Context(), filter) if err != nil { apierror.Render(w, r, err) return } if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{ Sessions: rows, Limit: filter.Limit, Offset: filter.Offset, }); writeErr != nil { slog.Default().ErrorContext(r.Context(), "write sessions response", scribe.Err(writeErr)) } } // Get handles GET /sessions/{tool}/{host}/{session_id}. The chi router // guarantees non-empty captures, but we defend in depth (a misconfigured // mount would otherwise produce a SQL query with empty keys). func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { scope, err := h.resolveScope(r) if err != nil { apierror.Render(w, r, err) return } tool := chi.URLParam(r, "tool") host := chi.URLParam(r, "host") sessionID := chi.URLParam(r, "session_id") if tool == "" || host == "" || sessionID == "" { apierror.Render(w, r, culpa.WithCode( culpa.WithPublic(culpa.New("tool, host, and session_id are required"), "tool, host, and session_id are required"), "INVALID", )) return } out, err := h.Repo.Get(r.Context(), scope, tool, host, sessionID) if err != nil { apierror.Render(w, r, err) return } if writeErr := httputil.WriteJSON(w, http.StatusOK, out); writeErr != nil { slog.Default().ErrorContext(r.Context(), "write session response", scribe.Err(writeErr)) } } // resolveScope reads the authenticated identity off the context and the // optional `?owner=` query parameter, then returns the appropriate // OwnerScope. Non-admin requests with `?owner=` set (any value, including // the requester's own user) are 403 — the parameter is admin-only and must // not be ignored silently for non-admins. func (h *Handler) resolveScope(r *http.Request) (OwnerScope, error) { id := auth.MustIdentity(r.Context()) param := r.URL.Query().Get("owner") if param == "" { return OwnerScope{User: id.User}, nil } if !id.IsAdmin { return OwnerScope{}, culpa.WithCode( culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"), "FORBIDDEN", ) } if param == allOwnersSentinel { return OwnerScope{User: id.User, AllOwners: true}, nil } owner := strings.ToLower(param) return OwnerScope{User: id.User, SpecificOwner: &owner}, nil } // clampLimit returns the effective limit: defaultLimit when missing, // non-numeric, or negative; capped at maxLimit when the parsed value // exceeds it. func clampLimit(raw string) int { if raw == "" { return defaultLimit } n, err := strconv.Atoi(raw) if err != nil || n < 0 { return defaultLimit } if n > maxLimit { return maxLimit } return n } // clampOffset returns the effective offset: 0 when missing, non-numeric, // or negative. func clampOffset(raw string) int { if raw == "" { return 0 } n, err := strconv.Atoi(raw) if err != nil || n < 0 { return 0 } return n }