package handlers import ( "database/sql" "encoding/json" "log" "net/http" "strings" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Request Types ─────────────────────────── type updateProfileRequest struct { DisplayName *string `json:"display_name,omitempty"` Email *string `json:"email,omitempty"` } type changePasswordRequest struct { CurrentPassword string `json:"current_password" binding:"required"` NewPassword string `json:"new_password" binding:"required,min=8,max=128"` } type profileResponse struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` DisplayName *string `json:"display_name"` Role string `json:"role"` Avatar *string `json:"avatar,omitempty"` Settings map[string]interface{} `json:"settings"` CreatedAt string `json:"created_at"` } // SettingsHandler manages user profile and preferences. type SettingsHandler struct { uekCache *crypto.UEKCache } // NewSettingsHandler creates a new handler. func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler { return &SettingsHandler{uekCache: uekCache} } // ── Get Profile ───────────────────────────── func (h *SettingsHandler) GetProfile(c *gin.Context) { userID := getUserID(c) var p profileResponse var settingsRaw string err := database.DB.QueryRow(database.Q(` SELECT id, username, email, display_name, role, avatar_url, settings::text, created_at FROM users WHERE id = $1 `), userID).Scan( &p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role, &p.Avatar, &settingsRaw, &p.CreatedAt, ) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load profile"}) return } p.Settings = make(map[string]interface{}) _ = json.Unmarshal([]byte(settingsRaw), &p.Settings) c.JSON(http.StatusOK, p) } // ── Update Profile ────────────────────────── func (h *SettingsHandler) UpdateProfile(c *gin.Context) { userID := getUserID(c) var req updateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.DisplayName != nil { _, err := database.DB.Exec( database.Q(`UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2`), *req.DisplayName, userID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update display name"}) return } } if req.Email != nil { email := strings.ToLower(strings.TrimSpace(*req.Email)) _, err := database.DB.Exec( database.Q(`UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2`), email, userID, ) if err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "email already taken"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update email"}) return } } h.GetProfile(c) } // ── Change Password ───────────────────────── func (h *SettingsHandler) ChangePassword(c *gin.Context) { userID := getUserID(c) var req changePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify current password var hash string err := database.DB.QueryRow( database.Q(`SELECT password_hash FROM users WHERE id = $1`), userID, ).Scan(&hash) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify password"}) return } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.CurrentPassword)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"}) return } // Hash new password newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } _, err = database.DB.Exec( database.Q(`UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`), string(newHash), userID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"}) return } // Re-wrap UEK with new password so personal BYOK keys remain accessible h.rewrapVault(userID, req.CurrentPassword, req.NewPassword) c.JSON(http.StatusOK, gin.H{"message": "password updated"}) } // ── Get User Settings (JSONB) ─────────────── func (h *SettingsHandler) GetSettings(c *gin.Context) { userID := getUserID(c) var settingsRaw string err := database.DB.QueryRow( database.Q(`SELECT settings::text FROM users WHERE id = $1`), userID, ).Scan(&settingsRaw) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) return } settings := make(map[string]interface{}) _ = json.Unmarshal([]byte(settingsRaw), &settings) c.JSON(http.StatusOK, settings) } // ── Update User Settings (JSONB merge) ────── func (h *SettingsHandler) UpdateSettings(c *gin.Context) { userID := getUserID(c) var incoming map[string]interface{} if err := c.ShouldBindJSON(&incoming); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } patch, err := json.Marshal(incoming) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings"}) return } // JSONB merge — existing keys preserved, incoming keys overwrite var mergeQuery string if database.IsSQLite() { mergeQuery = `UPDATE users SET settings = json_patch(settings, ?), updated_at = datetime('now') WHERE id = ?` } else { mergeQuery = `UPDATE users SET settings = settings || $1::jsonb, updated_at = NOW() WHERE id = $2` } _, err = database.DB.Exec(mergeQuery, string(patch), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) return } h.GetSettings(c) } // ── Vault Re-wrap ────────────────────────── // rewrapVault decrypts the UEK with the old password and re-encrypts it with // the new password. This keeps personal BYOK keys accessible after a password // change. Uses a new salt for forward secrecy. func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) { if h.uekCache == nil { return } var vaultSet bool var encUEK, salt, nonce []byte err := database.DB.QueryRow(database.Q(` SELECT vault_set, encrypted_uek, uek_salt, uek_nonce FROM users WHERE id = $1 `), userID).Scan(&vaultSet, &encUEK, &salt, &nonce) if err != nil || !vaultSet || len(encUEK) == 0 { return // No vault to re-wrap } // Decrypt UEK with old password oldPDK := crypto.DeriveKeyFromPassword(oldPassword, salt) uek, err := crypto.UnwrapUEK(encUEK, nonce, oldPDK) if err != nil { log.Printf("⚠ Vault re-wrap failed for user %s (unwrap): %v", userID, err) return } // Re-wrap with new password using fresh salt newSalt, err := crypto.GenerateSalt() if err != nil { log.Printf("⚠ Vault re-wrap failed for user %s (salt): %v", userID, err) return } newPDK := crypto.DeriveKeyFromPassword(newPassword, newSalt) newEncUEK, newNonce, err := crypto.WrapUEK(uek, newPDK) if err != nil { log.Printf("⚠ Vault re-wrap failed for user %s (wrap): %v", userID, err) return } _, err = database.DB.Exec(database.Q(` UPDATE users SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW() WHERE id = $4 `), newEncUEK, newSalt, newNonce, userID) if err != nil { log.Printf("⚠ Vault re-wrap failed for user %s (persist): %v", userID, err) return } // Session cache: UEK itself hasn't changed, just its wrapper h.uekCache.Store(userID, uek) log.Printf(" 🔐 Vault re-wrapped for user %s", userID) }