This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/settings.go
2026-02-28 01:40:31 +00:00

277 lines
8.1 KiB
Go

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)
}