Files
2026-08-24 13:06:29 +05:30

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
}