All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
257 lines
7.2 KiB
Go
257 lines
7.2 KiB
Go
package handlers
|
|
|
|
// settings.go — User profile, preferences, and password management.
|
|
//
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"switchboard-core/crypto"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// ── 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"`
|
|
Avatar *string `json:"avatar,omitempty"`
|
|
Settings map[string]interface{} `json:"settings"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
|
}
|
|
|
|
// SettingsHandler manages user profile and preferences.
|
|
type SettingsHandler struct {
|
|
stores store.Stores
|
|
uekCache *crypto.UEKCache
|
|
}
|
|
|
|
// NewSettingsHandler creates a new handler.
|
|
func NewSettingsHandler(stores store.Stores, uekCache *crypto.UEKCache) *SettingsHandler {
|
|
return &SettingsHandler{stores: stores, uekCache: uekCache}
|
|
}
|
|
|
|
// ── Get Profile ─────────────────────────────
|
|
|
|
func (h *SettingsHandler) GetProfile(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
|
|
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
|
|
var dn *string
|
|
if user.DisplayName != "" {
|
|
dn = &user.DisplayName
|
|
}
|
|
var avatar *string
|
|
if user.AvatarURL != "" {
|
|
avatar = &user.AvatarURL
|
|
}
|
|
|
|
settings := make(map[string]interface{})
|
|
if user.Settings != nil {
|
|
settings = user.Settings
|
|
}
|
|
|
|
c.JSON(http.StatusOK, profileResponse{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Email: user.Email,
|
|
DisplayName: dn,
|
|
Avatar: avatar,
|
|
Settings: settings,
|
|
CreatedAt: user.CreatedAt,
|
|
LastLoginAt: user.LastLoginAt,
|
|
})
|
|
}
|
|
|
|
// ── 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 {
|
|
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
|
|
}
|
|
}
|
|
|
|
if req.Email != nil {
|
|
email := strings.ToLower(strings.TrimSpace(*req.Email))
|
|
err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{
|
|
"email": email,
|
|
})
|
|
if err != nil {
|
|
if isDuplicateErr(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
|
|
}
|
|
|
|
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(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"})
|
|
return
|
|
}
|
|
|
|
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
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{})
|
|
if user.Settings != nil {
|
|
settings = user.Settings
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"settings": 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
h.GetSettings(c)
|
|
}
|
|
|
|
// ── Vault Re-wrap ──────────────────────────
|
|
|
|
func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
|
|
if h.uekCache == nil {
|
|
return
|
|
}
|
|
|
|
vaultSet, encUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(c_bg(), userID)
|
|
if err != nil || !vaultSet || len(encUEK) == 0 {
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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() }
|