~bigbes/lethe

lethe/internal/domain/session/handler.go -rw-r--r-- 6.0 KiB
0555d5d5 — Eugene Blikh chore: add BSD-2-Clause license 6 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
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("cwd"); v != "" {
		filter.Cwd = &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
}