package httpserver import ( "context" "encoding/json" "net/http" "strconv" "strings" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/repo" ) // The current-user endpoints. See api-contract.md §9. // // "Current" now means the user behind the session cookie, resolved by the // authentication middleware and read from the request context. Before Phase 3C // it meant the organization's oldest user, found with ORDER BY created_date // LIMIT 1 — a placeholder that was correct only because there was exactly one. // preferenceColumns maps the frontend's camelCase preference keys onto their // columns. Anything not listed here lives in user_preferences.extra — which is // where customSkills and customAgents, every account-authored definition, // currently are. var preferenceColumns = map[string]string{ "owliverDefault": "owliver_default", "compactDensity": "compact_density", "emailDigest": "email_digest", } // userColumns is the projection for a user record. const userColumns = `id::text AS id, legacy_id, full_name, email::text AS email, role, account_type, status, to_char(created_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_date, to_char(updated_date AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS updated_date` // updatableUserFields are the only columns PATCH /me may write, and this map is // the entire authority on that: a column absent from it cannot be reached by // this endpoint at all, whatever the request body says. // // full_name the Profile page's display-name field. // account_type which product surface the person is looking at — Employer or // // Talent. Layout.jsx writes it when the viewer switches. // Explicitly NOT an authorization field (Phase 3B decision 1); it // is a display attribute, and users.role is what authorizes. // // Everything else is server-owned. `role` used to be in this map, which meant // any signed-in user could promote themselves to admin with a one-line PATCH // the moment sessions existed. It was harmless while there was no // authentication and a live privilege-escalation path the instant there was. // See serverOwnedUserFields. var updatableUserFields = map[string]string{ "full_name": "text", "account_type": "text", } // serverOwnedUserFields are the fields a user must never write about // themselves, listed by name so an attempt can be recognised and logged rather // than silently dropped in with every other unknown key. // // They are ignored, not rejected: a PATCH body is "whichever fields the caller // sent", the endpoint has always ignored what it does not own, and the response // returns the user as they actually are — so a caller who asks for a role // change gets a 200 whose body shows the role unchanged. What is new is that // the attempt is now visible in the log, because a client asking to change its // own role is worth knowing about even when the answer is no. var serverOwnedUserFields = map[string]bool{ "id": true, "org_id": true, "role": true, "password_hash": true, "status": true, "email": true, "legacy_id": true, "last_login_at": true, "created_date": true, "updated_date": true, } func (s *Server) routeMe(mux *http.ServeMux) int { mux.HandleFunc("GET /api/v1/me", s.handleMeGet) mux.HandleFunc("PATCH /api/v1/me", s.handleMePatch) mux.HandleFunc("GET /api/v1/me/preferences", s.handlePreferencesGet) mux.HandleFunc("PATCH /api/v1/me/preferences", s.handlePreferencesPatch) return 4 } // userRecord reads one user by id, with preferences embedded. // // Embedded rather than a sibling resource because krowHooks.js:42 reads // `user?.preferences` straight off the object returned by auth.me(). // // The id is always one this server resolved from a session — never a value off // the request. There is deliberately no variant of this function that takes an // identifier from a caller. func (s *Server) userRecord(ctx context.Context, q repo.Querier, userID string) (domain.Record, error) { rows, err := q.Query(ctx, `SELECT `+userColumns+` FROM users WHERE id = $1::uuid`, userID) if err != nil { return nil, domain.Internal(err) } defer rows.Close() fields := rows.FieldDescriptions() if !rows.Next() { // Unreachable in practice: the middleware just read this row to build // the identity. Reported rather than papered over. return nil, domain.NotFound("User", "current") } vals, err := rows.Values() if err != nil { return nil, domain.Internal(err) } user := make(domain.Record, len(fields)+1) for i, f := range fields { user[string(f.Name)] = vals[i] } rows.Close() prefs, err := s.preferences(ctx, q, user["id"].(string)) if err != nil { return nil, err } user["preferences"] = prefs return user, nil } // preferences reads the three columns plus the extra blob, flattened into the // single camelCase object the frontend expects. func (s *Server) preferences(ctx context.Context, q repo.Querier, userID string) (map[string]any, error) { var owliver, compact, digest bool var extra []byte err := q.QueryRow(ctx, `SELECT owliver_default, compact_density, email_digest, extra FROM user_preferences WHERE user_id = $1::uuid`, userID). Scan(&owliver, &compact, &digest, &extra) out := map[string]any{} if err != nil { // No row yet is a legitimate state: the defaults below are the schema's. return map[string]any{"owliverDefault": true, "compactDensity": false, "emailDigest": true}, nil } if len(extra) > 0 { _ = json.Unmarshal(extra, &out) } out["owliverDefault"] = owliver out["compactDensity"] = compact out["emailDigest"] = digest return out, nil } func (s *Server) handleMeGet(w http.ResponseWriter, r *http.Request) { id, err := authctx.MustFrom(r.Context()) if err != nil { writeError(w, s.log, domain.Internal(err)) return } user, err := s.userRecord(r.Context(), s.db.Pool, id.UserID) if err != nil { writeError(w, s.log, err) return } writeRecord(w, http.StatusOK, user) } func (s *Server) handleMePatch(w http.ResponseWriter, r *http.Request) { id, err := authctx.MustFrom(r.Context()) if err != nil { writeError(w, s.log, domain.Internal(err)) return } body, err := decodeBody(r) if err != nil { writeError(w, s.log, err) return } // The row to update is the session's user. Note what is NOT consulted: the // body may contain an "id", and it is ignored — writing to whichever user // the caller names is the whole of the vulnerability this avoids. sets, args := []string{}, []any{id.UserID} details := map[string]string{} for k, v := range body { if k == "preferences" { details[k] = "update preferences through /api/v1/me/preferences" continue } pgType, ok := updatableUserFields[k] if !ok { if serverOwnedUserFields[k] { s.log.Warn("ignored an attempt to write a server-owned user field", "field", k, "user_id", id.UserID, "session_id", id.SessionID) } continue // server-owned, or simply not a column: ignored either way } str, isStr := v.(string) if !isStr { details[k] = "expected a string" continue } args = append(args, str) sets = append(sets, k+" = $"+strconv.Itoa(len(args))+"::"+pgType) } if len(details) > 0 { writeError(w, s.log, domain.Validation("User payload is not valid", details)) return } if len(sets) > 0 { // Every column name in `sets` came from updatableUserFields, which is a // literal map in this file. No identifier here is caller-supplied; the // values are all bind parameters. q := "UPDATE users SET " + strings.Join(sets, ", ") + ", updated_date = now() WHERE id = $1::uuid" if _, err := s.db.Pool.Exec(r.Context(), q, args...); err != nil { writeError(w, s.log, domain.Internal(err)) return } } user, err := s.userRecord(r.Context(), s.db.Pool, id.UserID) if err != nil { writeError(w, s.log, err) return } writeRecord(w, http.StatusOK, user) } func (s *Server) handlePreferencesGet(w http.ResponseWriter, r *http.Request) { id, err := authctx.MustFrom(r.Context()) if err != nil { writeError(w, s.log, domain.Internal(err)) return } prefs, err := s.preferences(r.Context(), s.db.Pool, id.UserID) if err != nil { writeError(w, s.log, err) return } writeJSON(w, http.StatusOK, envelope{Data: prefs}) } // handlePreferencesPatch shallow-merges the supplied keys and returns the whole // merged object, matching auth.updatePreferences(). func (s *Server) handlePreferencesPatch(w http.ResponseWriter, r *http.Request) { id, err := authctx.MustFrom(r.Context()) if err != nil { writeError(w, s.log, domain.Internal(err)) return } body, err := decodeBody(r) if err != nil { writeError(w, s.log, err) return } userID := id.UserID current, err := s.preferences(r.Context(), s.db.Pool, userID) if err != nil { writeError(w, s.log, err) return } merged := current if merged == nil { merged = map[string]any{} } details := map[string]string{} for k, v := range body { if _, isColumn := preferenceColumns[k]; isColumn { if _, ok := v.(bool); !ok { details[k] = "expected a boolean" continue } } merged[k] = v } if len(details) > 0 { writeError(w, s.log, domain.Validation("preferences payload is not valid", details)) return } // Split the merged object back into its columns and the extra blob. extra := map[string]any{} for k, v := range merged { if _, isColumn := preferenceColumns[k]; !isColumn { extra[k] = v } } extraJSON, err := json.Marshal(extra) if err != nil { writeError(w, s.log, domain.Validation("preferences are not encodable as JSON", nil)) return } _, err = s.db.Pool.Exec(r.Context(), `INSERT INTO user_preferences (user_id, owliver_default, compact_density, email_digest, extra, updated_date) VALUES ($1::uuid, $2::boolean, $3::boolean, $4::boolean, $5::jsonb, now()) ON CONFLICT (user_id) DO UPDATE SET owliver_default = EXCLUDED.owliver_default, compact_density = EXCLUDED.compact_density, email_digest = EXCLUDED.email_digest, extra = EXCLUDED.extra, updated_date = now()`, userID, truthy(merged["owliverDefault"], true), truthy(merged["compactDensity"], false), truthy(merged["emailDigest"], true), extraJSON) if err != nil { writeError(w, s.log, domain.Internal(err)) return } prefs, err := s.preferences(r.Context(), s.db.Pool, userID) if err != nil { writeError(w, s.log, err) return } writeJSON(w, http.StatusOK, envelope{Data: prefs}) } func truthy(v any, fallback bool) bool { if b, ok := v.(bool); ok { return b } return fallback }