package cabana import ( "context" "errors" "log/slog" "net/http" "slices" "sort" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" ) // Metadata keys of an admin's UI preferences in backend_users.metadata, next // to the subtitle and whatever keys a WinterCMS copy brought along. const ( metadataAdminTheme = "admin_theme" metadataAdminLocale = "admin_locale" ) // Preference keys PUT /account/preferences accepts. const ( preferenceTheme = "theme" preferenceLocale = "locale" ) // themeSystem follows the browser's prefers-color-scheme; it is the default // and is stored as an absent key. const themeSystem = "system" // themeChoices are the values of the theme preference. var themeChoices = []string{themeSystem, "light", "dark"} // preferenceKeys are the only keys PUT /account/preferences accepts. var preferenceKeys = []string{preferenceTheme, preferenceLocale} // preferencesOf reads an admin's UI preferences from the stored metadata. A // theme outside themeChoices is system; a locale the translator has no admin // strings for is "", so a value that lost its support degrades to the // request locale. func preferencesOf(user BackendUser, tr *phrasebook.Translator) AdminPreferences { prefs := AdminPreferences{Theme: themeSystem} if theme := metadataString(user.Metadata, metadataAdminTheme); slices.Contains(themeChoices, theme) { prefs.Theme = theme } if locale := metadataString(user.Metadata, metadataAdminLocale); locale != "" && slices.Contains(tr.Locales(adminLangPrefix), locale) { prefs.Locale = locale } return prefs } // accountPreferencesGet serves GET /account/preferences: the signed-in // admin's own UI preferences. func (s *service) accountPreferencesGet(w http.ResponseWriter, r *http.Request) { principal := accountPrincipal(w, r) if principal == nil { return } db, err := s.db() if err != nil { slog.Default().ErrorContext(r.Context(), "cabana: account preferences without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } var user BackendUser err = db.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && !user.IsActivated) { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } if err != nil { s.writeAccountError(w, r, err) return } WriteData(w, http.StatusOK, preferencesOf(user, s.translator()), map[string]any{}) } // accountPreferencesUpdate serves PUT /account/preferences: the signed-in // admin saves their own theme and admin locale. The row is the principal's, // never a request value. Each present key is saved, an absent key keeps its // stored value. Only the metadata column is written: a UI preference is not // a profile edit, so updated_at stays. func (s *service) accountPreferencesUpdate(w http.ResponseWriter, r *http.Request) { principal := accountPrincipal(w, r) if principal == nil { return } body, err := s.decodeCappedObject(w, r) if err != nil { writeRelationError(w, err) return } ctx, tr := r.Context(), s.translator() if details := refusedPreferenceKeys(ctx, tr, body); len(details) > 0 { writeCRUDError(w, &ValidationError{Details: details}) return } updates, details := validPreferences(ctx, tr, body) if len(details) > 0 { writeCRUDError(w, &ValidationError{Details: details}) return } db, err := s.db() if err != nil { slog.Default().ErrorContext(ctx, "cabana: account preferences without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } var saved BackendUser err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { user, err := lockAccount(ctx, tx, principal.ID) if err != nil { return err } metadata := user.Metadata keys := make([]string, 0, len(updates)) for key := range updates { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { metadata, err = setMetadataString(metadata, key, updates[key]) if errors.Is(err, errMetadataNotObject) { message := backendMessage(ctx, tr, "myaccount.preferences_unavailable", "The preference cannot be saved because the stored profile metadata is not a JSON object.") details := map[string]any{} for field := range body { details[field] = []string{message} } return &ValidationError{Details: details} } if err != nil { return err } } if sameMetadata(user.Metadata, metadata) { saved = user return nil } q := tx.Session(&gorm.Session{NewDB: true, Context: ctx}) if err := q.Model(&BackendUser{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{"metadata": metadata}).Error; err != nil { return err } user.Metadata = metadata saved = user return nil }) if err != nil { s.writeAccountError(w, r, err) return } WriteData(w, http.StatusOK, preferencesOf(saved, tr), map[string]any{}) } // refusedPreferenceKeys answers one detail per body key PUT // /account/preferences does not accept. func refusedPreferenceKeys(ctx context.Context, tr *phrasebook.Translator, body map[string]any) map[string]any { var details map[string]any for key := range body { if slices.Contains(preferenceKeys, key) { continue } if details == nil { details = map[string]any{} } details[key] = []string{backendMessage(ctx, tr, "myaccount.field_not_editable", "This field cannot be changed on My account.")} } return details } // validPreferences checks the present preference keys and returns the // metadata values to store: system and "" are stored as an absent key, so an // admin who never chose keeps NULL metadata. func validPreferences(ctx context.Context, tr *phrasebook.Translator, body map[string]any) (map[string]string, map[string]any) { updates := map[string]string{} details := map[string]any{} if raw, ok := body[preferenceTheme]; ok { theme, isString := raw.(string) switch { case !isString || !slices.Contains(themeChoices, theme): details[preferenceTheme] = []string{fieldMessage(ctx, tr, "in", preferenceTheme, nil)} case theme == themeSystem: updates[metadataAdminTheme] = "" default: updates[metadataAdminTheme] = theme } } if raw, ok := body[preferenceLocale]; ok { locale, isString := raw.(string) switch { case !isString || (locale != "" && !slices.Contains(tr.Locales(adminLangPrefix), locale)): details[preferenceLocale] = []string{fieldMessage(ctx, tr, "in", preferenceLocale, nil)} default: updates[metadataAdminLocale] = locale } } return updates, details } // sameMetadata reports whether two stored metadata values are equal, NULL // equal only to NULL. func sameMetadata(a, b *string) bool { if a == nil || b == nil { return a == nil && b == nil } return *a == *b }