Files
silo-server/internal/api/handlers/settings_values_admin.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

158 lines
5.8 KiB
Go

package handlers
import (
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const adminNavigationShortcutRepairMessage = "Use whole-document PUT on this admin resource to clear or replace navigation shortcuts"
// Admin projections of the canonical settings API. These replace the string
// registry's /admin/users/{id}/settings* and device-settings* routes with the
// same typed surface clients use on /settings/values: the same validation, the
// same scopes, the same response shapes. The only differences are that the
// target user comes from the path instead of the session, and that profile and
// device ids come from the query string — an admin has no session claim to the
// user they are inspecting.
//
// Mounted behind requireActingAdmin next to the other /admin/users routes, so
// authorization is the router group's, not re-checked here.
// HandleAdminListUserSettingValues handles
// GET /admin/users/{id}/settings/values: every explicit value the target user
// has stored, across all scopes. It deliberately lists stored rows rather than
// resolving: the admin surface answers "what overrides exist" (and offers a
// reset per row), which is the same question the session route's per-scope GET
// answers for one identity.
func (h *SettingValuesHandler) HandleAdminListUserSettingValues(w http.ResponseWriter, r *http.Request) {
store, _, ok := h.adminTargetStore(w, r)
if !ok {
return
}
values, err := store.ListAllSettingValues(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings")
return
}
out := make([]settingValueResponse, 0, len(values))
for _, value := range values {
out = append(out, settingValueToResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{
fieldValues: out,
fieldRevision: h.contract.Revision,
})
}
// HandleAdminSetUserSettingValue handles
// PUT /admin/users/{id}/settings/values/{key}: write an explicit value at one
// scope on behalf of the target user, through the same validation and
// idempotency path as the session route.
func (h *SettingValuesHandler) HandleAdminSetUserSettingValue(w http.ResponseWriter, r *http.Request) {
store, userID, ok := h.adminTargetStore(w, r)
if !ok {
return
}
identity, ok := h.adminIdentityFromRequest(w, r)
if !ok {
return
}
// The session route's profile is validated by middleware; the admin names
// one in the query, so its existence is checked here. Postgres would refuse
// an orphan row on its profile FK anyway — checking first turns that 500
// into a 404 and gives SQLite the same behavior.
if identity.ProfileID != "" && !adminProfileExists(w, r, store, identity.ProfileID) {
return
}
h.setValueAt(w, r, store, userID, identity)
}
// HandleAdminDeleteUserSettingValue handles
// DELETE /admin/users/{id}/settings/values/{key}: remove the target user's
// explicit value at one scope so inheritance applies again.
func (h *SettingValuesHandler) HandleAdminDeleteUserSettingValue(w http.ResponseWriter, r *http.Request) {
store, userID, ok := h.adminTargetStore(w, r)
if !ok {
return
}
identity, ok := h.adminIdentityFromRequest(w, r)
if !ok {
return
}
if identity.Key == settingskeys.NavShortcuts {
writeError(w, http.StatusBadRequest, "atomic_update_required",
adminNavigationShortcutRepairMessage)
return
}
h.deleteValueAt(w, r, store, userID, identity)
}
// adminTargetStore resolves the {id} path parameter to the target user's
// store.
func (h *SettingValuesHandler) adminTargetStore(
w http.ResponseWriter, r *http.Request,
) (userstore.UserStore, int, bool) {
userID, ok := parseAdminUserIDParam(w, r)
if !ok {
return nil, 0, false
}
store, err := h.storeProvider.ForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return nil, 0, false
}
if store == nil {
writeError(w, http.StatusNotFound, "not_found", "User store not found")
return nil, 0, false
}
return store, userID, true
}
// adminIdentityFromRequest is identityFromRequest with the profile and device
// taken from the query string instead of the session: the admin is not the
// user being addressed, so there are no session headers to trust. Everything
// after that — content-scope ids, identity validation, the contract's scope
// allowance — is the shared completeIdentity path.
func (h *SettingValuesHandler) adminIdentityFromRequest(
w http.ResponseWriter, r *http.Request,
) (userstore.SettingIdentity, bool) {
key, scope, ok := h.keyedScopeFromRequest(w, r)
if !ok {
return userstore.SettingIdentity{}, false
}
query := r.URL.Query()
identity := userstore.SettingIdentity{Key: key, Scope: scope}
if scope != settingscontract.ScopeAccount {
identity.ProfileID = strings.TrimSpace(query.Get("profile_id"))
if identity.ProfileID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"profile_id is required for this scope")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileClient {
identity.ClientFamily = settingscontract.ClientFamily(strings.TrimSpace(query.Get("client_family")))
if !identity.ClientFamily.Valid() {
writeError(w, http.StatusBadRequest, "bad_request",
"client_family must be one of tv, mobile, tablet, desktop or web")
return userstore.SettingIdentity{}, false
}
}
if scope == settingscontract.ScopeProfileDevice {
identity.DeviceID = strings.TrimSpace(query.Get("device_id"))
if identity.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"device_id is required for a device override")
return userstore.SettingIdentity{}, false
}
}
return h.completeIdentity(w, r.Context(), query, identity)
}