Changeset 0.29.0 (#195)
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
package handlers
|
||||
|
||||
// settings.go — User profile, preferences, and password management.
|
||||
//
|
||||
// v0.29.0: Raw SQL replaced with UserStore methods.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -12,7 +16,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Request Types ───────────────────────────
|
||||
@@ -41,12 +45,13 @@ type profileResponse struct {
|
||||
|
||||
// SettingsHandler manages user profile and preferences.
|
||||
type SettingsHandler struct {
|
||||
stores store.Stores
|
||||
uekCache *crypto.UEKCache
|
||||
}
|
||||
|
||||
// NewSettingsHandler creates a new handler.
|
||||
func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler {
|
||||
return &SettingsHandler{uekCache: uekCache}
|
||||
func NewSettingsHandler(stores store.Stores, uekCache *crypto.UEKCache) *SettingsHandler {
|
||||
return &SettingsHandler{stores: stores, uekCache: uekCache}
|
||||
}
|
||||
|
||||
// ── Get Profile ─────────────────────────────
|
||||
@@ -54,31 +59,37 @@ func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler {
|
||||
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,
|
||||
COALESCE(NULLIF(settings::text, 'null'), '{}'),
|
||||
created_at, last_login_at
|
||||
FROM users WHERE id = $1
|
||||
`), userID).Scan(
|
||||
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
|
||||
&p.Avatar, &settingsRaw,
|
||||
database.ST(&p.CreatedAt), database.SNT(&p.LastLoginAt),
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
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
|
||||
|
||||
var dn *string
|
||||
if user.DisplayName != "" {
|
||||
dn = &user.DisplayName
|
||||
}
|
||||
var avatar *string
|
||||
if user.AvatarURL != "" {
|
||||
avatar = &user.AvatarURL
|
||||
}
|
||||
|
||||
p.Settings = make(map[string]interface{})
|
||||
_ = json.Unmarshal([]byte(settingsRaw), &p.Settings)
|
||||
settings := make(map[string]interface{})
|
||||
if user.Settings != nil {
|
||||
settings = user.Settings
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, p)
|
||||
c.JSON(http.StatusOK, profileResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
DisplayName: dn,
|
||||
Role: user.Role,
|
||||
Avatar: avatar,
|
||||
Settings: settings,
|
||||
CreatedAt: user.CreatedAt,
|
||||
LastLoginAt: user.LastLoginAt,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Update Profile ──────────────────────────
|
||||
@@ -93,11 +104,9 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
|
||||
"display_name": *req.DisplayName,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update display name"})
|
||||
return
|
||||
}
|
||||
@@ -105,12 +114,11 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
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,
|
||||
)
|
||||
err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
|
||||
"email": email,
|
||||
})
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
if isDuplicateErr(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already taken"})
|
||||
return
|
||||
}
|
||||
@@ -133,40 +141,31 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
var hash string
|
||||
err := database.DB.QueryRow(
|
||||
database.Q(`SELECT password_hash FROM users WHERE id = $1`), userID,
|
||||
).Scan(&hash)
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
||||
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 {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []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 {
|
||||
if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
|
||||
"password_hash": string(newHash),
|
||||
}); 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"})
|
||||
}
|
||||
|
||||
@@ -175,20 +174,16 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var settingsRaw string
|
||||
// Handle three states: SQL NULL, JSON null, or valid JSON object.
|
||||
// NULLIF(settings, 'null') converts JSON null to SQL NULL,
|
||||
// then COALESCE handles both SQL NULL cases.
|
||||
err := database.DB.QueryRow(
|
||||
database.Q(`SELECT COALESCE(NULLIF(settings::text, 'null'), '{}') FROM users WHERE id = $1`), userID,
|
||||
).Scan(&settingsRaw)
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
||||
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)
|
||||
if user.Settings != nil {
|
||||
settings = user.Settings
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
@@ -210,28 +205,7 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// JSONB merge — existing keys preserved, incoming keys overwrite.
|
||||
// Must handle three column states:
|
||||
// 1. SQL NULL (never saved)
|
||||
// 2. JSON literal null (corrupted by earlier bug)
|
||||
// 3. JSON array (corrupted by repeated appends to null)
|
||||
// 4. Valid JSON object (normal)
|
||||
// NULLIF converts JSON null → SQL NULL, then COALESCE → '{}',
|
||||
// and we enforce jsonb_typeof = 'object' to catch array corruption.
|
||||
var mergeQuery string
|
||||
if database.IsSQLite() {
|
||||
mergeQuery = `UPDATE users SET settings = json_patch(
|
||||
CASE WHEN settings IS NULL OR settings = 'null' OR json_type(settings) != 'object'
|
||||
THEN '{}' ELSE settings END,
|
||||
?), updated_at = datetime('now') WHERE id = ?`
|
||||
} else {
|
||||
mergeQuery = `UPDATE users SET settings = (
|
||||
CASE WHEN settings IS NULL OR settings = 'null'::jsonb OR jsonb_typeof(settings) != 'object'
|
||||
THEN '{}'::jsonb ELSE settings END
|
||||
) || $1::jsonb, updated_at = NOW() WHERE id = $2`
|
||||
}
|
||||
_, err = database.DB.Exec(mergeQuery, string(patch), userID)
|
||||
if err != nil {
|
||||
if err := h.stores.Users.MergeSettings(c.Request.Context(), userID, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"})
|
||||
return
|
||||
}
|
||||
@@ -241,25 +215,16 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
// ── 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)
|
||||
vaultSet, encUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(c_bg(), userID)
|
||||
if err != nil || !vaultSet || len(encUEK) == 0 {
|
||||
return // No vault to re-wrap
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt UEK with old password
|
||||
oldPDK := crypto.DeriveKeyFromPassword(oldPassword, salt)
|
||||
uek, err := crypto.UnwrapUEK(encUEK, nonce, oldPDK)
|
||||
if err != nil {
|
||||
@@ -267,7 +232,6 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
|
||||
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)
|
||||
@@ -281,17 +245,15 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
|
||||
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 {
|
||||
if err := h.stores.Users.UpdateVaultKeys(c_bg(), userID, newEncUEK, newSalt, newNonce); 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)
|
||||
}
|
||||
|
||||
// c_bg returns context.Background() — short alias for vault operations
|
||||
// that run outside the request lifecycle.
|
||||
func c_bg() context.Context { return context.Background() }
|
||||
|
||||
Reference in New Issue
Block a user