327 lines
10 KiB
Go
327 lines
10 KiB
Go
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
|
|
}
|