* 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
158 lines
5.8 KiB
Go
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)
|
|
}
|