// Package handlers provides HTTP request/response handlers for the bookhoard application. // It includes handlers for authentication, libraries, media items, reading, collections, // dashboards, devices, analytics, and various system features. package handlers import ( "bookhoard/internal/database" "bookhoard/internal/middleware" "context" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" "golang.org/x/crypto/bcrypt" ) const ( // Session duration constants // Follows same pattern as refresh_token.go SessionDuration = 7 * 24 * time.Hour // 7 days ) // SessionDurationSec is the session duration in seconds for use in cookies and API responses // Note: This is computed from SessionDuration to avoid magic numbers var SessionDurationSec = int(SessionDuration.Seconds()) var secure = os.Getenv("COOKIE_SECURE") type AuthHandler struct { db *database.Queries jwtKey []byte loginAttemptTracker *middleware.LoginAttemptTracker } func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler { return &AuthHandler{ db: db, jwtKey: []byte(jwtSecret), loginAttemptTracker: loginAttemptTracker, } } type RegisterRequest struct { Email string `form:"email" json:"email" validate:"required,email"` Username string `form:"username" json:"username" validate:"required,min=3,max=50"` Password string `form:"password" json:"password" validate:"required,passwordcomplex"` FirstName string `form:"first_name" json:"first_name,omitempty"` LastName string `form:"last_name" json:"last_name,omitempty"` Role string `form:"role" json:"role,omitempty"` } type LoginRequest struct { Login string `form:"login" json:"login" validate:"required"` // email or username Password string `form:"password" json:"password" validate:"required"` } type AuthResponse struct { Token string `json:"access_token"` RefreshToken string `json:"refresh_token,omitempty"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` User UserProfile `json:"user"` } type UserProfile struct { ID string `json:"id"` Email string `json:"email"` Username string `json:"username"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Role string `json:"role"` } type UpdateProfileRequest struct { Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` Email string `json:"email,omitempty" validate:"omitempty,email"` FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` Theme string `json:"theme,omitempty" validate:"omitempty"` Timezone string `json:"timezone,omitempty" validate:"omitempty"` } type AdminUpdateUserRequest struct { Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` Email string `json:"email,omitempty" validate:"omitempty,email"` FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` Theme string `json:"theme,omitempty" validate:"omitempty"` Timezone string `json:"timezone,omitempty" validate:"omitempty"` Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` } // Register handles POST /api/auth/register func (h *AuthHandler) Register(c *echo.Context) error { email := c.FormValue("email") username := c.FormValue("username") password := c.FormValue("password") firstName := c.FormValue("first_name") lastName := c.FormValue("last_name") role := c.FormValue("role") if email == "" || username == "" || password == "" { req := RegisterRequest{} if err := c.Bind(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
Invalid request
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } email = req.Email username = req.Username password = req.Password firstName = req.FirstName lastName = req.LastName role = req.Role } req := RegisterRequest{Email: email, Username: username, Password: password, FirstName: firstName, LastName: lastName, Role: role} if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } req.Username = strings.TrimSpace(req.Username) if req.Username == "" { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
Username cannot be empty or whitespace
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"}) } if req.Role != "" { req.Role = strings.ToLower(req.Role) } if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusConflict, `
Email already exists
`) } return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"}) } if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusConflict, `
Username already exists
`) } return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"}) } users, err := h.db.ListUsers(c.Request().Context()) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to check existing users: `+err.Error()+`
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users: " + err.Error()}) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to hash password
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) } adminExists := false for _, u := range users { if u.Role == "admin" { adminExists = true break } } var userRole string if len(users) == 0 { userRole = "admin" } else { userRole = req.Role if userRole == "" { userRole = "user" } if userRole != "user" && userRole != "admin" { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
Invalid role. Must be 'user' or 'admin'
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"}) } if userRole == "admin" && adminExists { user, ok := c.Get("user").(database.Users) if !ok || user.Role != "admin" { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusForbidden, `
Only existing administrators can create admin accounts
`) } return c.JSON(http.StatusForbidden, map[string]string{"error": "only administrators can create admin accounts"}) } } } user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{ Email: req.Email, Username: req.Username, PasswordHash: string(hashedPassword), FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""}, LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, Theme: pgtype.Text{String: "tokyo-night", Valid: true}, Role: userRole, }) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
`+err.Error()+`
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to create default collections
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"}) } accessToken, err := h.generateJWTWithAllClaims( uuid.UUID(user.ID.Bytes).String(), user.Role, user.Email, user.Username, ) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to generate token
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) } // Set HTTP-only cookie for browser-based authentication cookie := &http.Cookie{ Name: "token", Value: accessToken, Path: "/", HttpOnly: true, Secure: secure == "true", // TODO: Set to true in production with HTTPS SameSite: http.SameSiteLaxMode, MaxAge: SessionDurationSec, } c.SetCookie(cookie) _, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to generate refresh token
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate refresh token"}) } if c.Request().Header.Get("HX-Request") == "true" { html := fmt.Sprintf(`
Registration successful! Redirecting...
`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username)) return c.HTML(http.StatusCreated, html) } if user.FirstName.Valid { firstName = user.FirstName.String } if user.LastName.Valid { lastName = user.LastName.String } return c.JSON(http.StatusCreated, AuthResponse{ Token: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", ExpiresIn: SessionDurationSec, User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, Username: user.Username, FirstName: firstName, LastName: lastName, Role: user.Role, }, }) } // Login handles POST /api/auth/login func (h *AuthHandler) Login(c *echo.Context) error { login := c.FormValue("login") password := c.FormValue("password") if login == "" || password == "" { req := LoginRequest{} if err := c.Bind(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
Invalid request
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } login = req.Login password = req.Password } req := LoginRequest{Login: login, Password: password} if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } ip := c.RealIP() if ip == "" { ip = c.Request().RemoteAddr } locked, remainingTime := h.loginAttemptTracker.IsLocked(login) if locked { errMsg := fmt.Sprintf("Account locked. Try again in %d minutes", int(remainingTime.Minutes())+1) if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusTooManyRequests, `
`+errMsg+`
`) } return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg}) } user, err := h.db.GetUserForLogin(c.Request().Context(), req.Login) if err != nil { locked, remainingTime := h.loginAttemptTracker.RecordFailedAttempt(login) if locked { errMsg := fmt.Sprintf("Too many failed attempts. Account locked for %d minutes", int(remainingTime.Minutes())+1) if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusTooManyRequests, `
`+errMsg+`
`) } return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg}) } if c.Request().Header.Get("HX-Request") == "true" { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { locked, remainingTime := h.loginAttemptTracker.RecordFailedAttempt(login) if locked { errMsg := fmt.Sprintf("Too many failed attempts. Account locked for %d minutes", int(remainingTime.Minutes())+1) if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusTooManyRequests, `
`+errMsg+`
`) } return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg}) } if c.Request().Header.Get("HX-Request") == "true" { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } h.loginAttemptTracker.ClearAttempts(login) accessToken, err := h.generateJWTWithAllClaims( uuid.UUID(user.ID.Bytes).String(), user.Role, user.Email, user.Username, ) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to generate token
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) } // Set HTTP-only cookie for browser-based authentication cookie := &http.Cookie{ Name: "token", Value: accessToken, Path: "/", HttpOnly: true, Secure: secure == "true", // TODO: Set to true in production with HTTPS SameSite: http.SameSiteLaxMode, MaxAge: SessionDurationSec, } c.SetCookie(cookie) _, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to generate refresh token
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate refresh token"}) } if c.Request().Header.Get("HX-Request") == "true" { redirect := c.FormValue("redirect") if redirect == "" { redirect = "/dashboard" } html := fmt.Sprintf(`
Login successful! Redirecting...
`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), redirect) return c.HTML(http.StatusOK, html) } firstName := "" if user.FirstName.Valid { firstName = user.FirstName.String } lastName := "" if user.LastName.Valid { lastName = user.LastName.String } return c.JSON(http.StatusOK, AuthResponse{ Token: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", ExpiresIn: SessionDurationSec, User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, Username: user.Username, FirstName: firstName, LastName: lastName, Role: user.Role, }, }) } // GetProfile handles GET /api/auth/profile func (h *AuthHandler) GetProfile(c *echo.Context) error { user := MustGetAuthenticatedUser(c) firstName := "" if user.FirstName.Valid { firstName = user.FirstName.String } lastName := "" if user.LastName.Valid { lastName = user.LastName.String } return c.JSON(http.StatusOK, UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, Username: user.Username, FirstName: firstName, LastName: lastName, Role: user.Role, }) } // UpdateProfile handles PUT /api/auth/profile (self-edit) and PUT /api/auth/profile/:id (admin edit) // Combined handler for both self-service and admin profile updates func (h *AuthHandler) UpdateProfile(c *echo.Context) error { currentUser := MustGetAuthenticatedUser(c) // Determine target user: URL param (admin mode) or current user (self-edit) targetUserID := c.Param("id") var targetUserUUID pgtype.UUID isAdminMode := targetUserID != "" if isAdminMode { // Admin editing another user - validate admin role if currentUser.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } parsedUUID, err := uuid.Parse(targetUserID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true} } else { // Self-edit mode targetUserUUID = currentUser.ID } var req AdminUpdateUserRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Handle role change (admin mode only) if req.Role != "" && isAdminMode { if req.Role != "admin" && req.Role != "user" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"}) } // Check if this is the last admin (preventing demotion) users, err := h.db.ListUsers(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"}) } adminCount := 0 currentTargetUserRole := "" for _, user := range users { if user.Role == "admin" { adminCount++ } if user.ID.Bytes == targetUserUUID.Bytes { currentTargetUserRole = user.Role } } // Prevent demoting the last admin if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 { return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"}) } // Update role _, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{ ID: targetUserUUID, Role: req.Role, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update username (if provided) if req.Username != "" { existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username) if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes { return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"}) } err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{ ID: targetUserUUID, Username: req.Username, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update timezone if req.Timezone != "" { if _, err := time.LoadLocation(req.Timezone); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "Invalid timezone", }) } err := h.db.UpdateUserTimezone(c.Request().Context(), database.UpdateUserTimezoneParams{ ID: targetUserUUID, Timezone: pgtype.Text{String: req.Timezone, Valid: true}, }) if err != nil { return err } } // Update email (if provided) if req.Email != "" { existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email) if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes { return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"}) } err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{ ID: targetUserUUID, Email: req.Email, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update first/last name (if provided) if req.FirstName != "" || req.LastName != "" { err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{ ID: targetUserUUID, FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""}, LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } // Update theme (if provided) if req.Theme != "" { err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{ ID: targetUserUUID, Theme: pgtype.Text{String: req.Theme, Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"}) } // ListUsers handles GET /api/auth/users func (h *AuthHandler) ListUsers(c *echo.Context) error { users, err := h.db.ListUsers(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, users) } // normalizePath cleans and normalizes folder paths for consistent storage and comparison func normalizePath(path string) string { fmt.Printf("normalizePath input: '%s'\n", path) var cleaned string // Handle home directory expansion (~) if strings.HasPrefix(path, "~/") { // Keep the original path for ~ to preserve user's formatting // Just normalize separators and that's it cleaned = strings.ReplaceAll(path, "\\", "/") } else { // Clean the path to remove redundant separators, ., .. etc. cleaned = filepath.Clean(path) // Convert to consistent path separators (use forward slashes for storage) cleaned = strings.ReplaceAll(cleaned, "\\", "/") // Remove trailing slash unless it's root path if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") { cleaned = strings.TrimSuffix(cleaned, "/") } } fmt.Printf("normalizePath output: '%s'\n", cleaned) return cleaned } type UpdateThemeRequest struct { Theme string `json:"theme" validate:"required"` } // UpdateTheme handles PUT /api/auth/theme func (h *AuthHandler) UpdateTheme(c *echo.Context) error { user := MustGetAuthenticatedUser(c) var req UpdateThemeRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{ ID: user.ID, Theme: pgtype.Text{String: req.Theme, Valid: req.Theme != ""}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "theme updated successfully"}) } type UpdateUsernameRequest struct { Username string `json:"username" validate:"required,min=3,max=50"` } // UpdateUsername handles PUT /api/auth/username func (h *AuthHandler) UpdateUsername(c *echo.Context) error { user := MustGetAuthenticatedUser(c) var req UpdateUsernameRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Check if username is already taken by another user existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username) if err == nil && existingUser.ID.Bytes != user.ID.Bytes { return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"}) } // Update username err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{ ID: user.ID, Username: req.Username, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "username updated successfully"}) } type UpdateEmailRequest struct { Email string `json:"email" validate:"required,email"` } // UpdateEmail handles PUT /api/auth/email func (h *AuthHandler) UpdateEmail(c *echo.Context) error { user := MustGetAuthenticatedUser(c) var req UpdateEmailRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Check if email is already taken by another user existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email) if err == nil && existingUser.ID.Bytes != user.ID.Bytes { return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"}) } // Update email err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{ ID: user.ID, Email: req.Email, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "email updated successfully"}) } type UpdatePasswordRequest struct { CurrentPassword string `json:"current_password" validate:"required"` NewPassword string `json:"new_password" validate:"required,passwordcomplex"` ConfirmPassword string `json:"confirm_password" validate:"required"` } // UpdatePassword handles PUT /api/auth/password (self-change) and PUT /api/auth/password/:id (admin reset) // Combined handler for both self-service password change and admin password reset func (h *AuthHandler) UpdatePassword(c *echo.Context) error { currentUser := MustGetAuthenticatedUser(c) // Determine target user: URL param (admin mode) or current user (self-change) targetUserID := c.Param("id") var targetUserUUID pgtype.UUID isAdminMode := targetUserID != "" if isAdminMode { // Admin resetting another user's password - validate admin role if currentUser.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } parsedUUID, err := uuid.Parse(targetUserID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true} } else { // Self-change mode targetUserUUID = currentUser.ID } type PasswordRequest struct { CurrentPassword string `json:"current_password,omitempty"` NewPassword string `json:"new_password" validate:"required,passwordcomplex"` ConfirmPassword string `json:"confirm_password" validate:"required"` } var req PasswordRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Self-change mode: require current password if !isAdminMode { if req.CurrentPassword == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"}) } // Get current password hash passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"}) } // Verify current password err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)) if err != nil { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"}) } } // Validate new password matches confirmation if req.NewPassword != req.ConfirmPassword { return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"}) } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) } // Update password err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{ ID: targetUserUUID, PasswordHash: string(hashedPassword), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"}) } // DeleteUser handles DELETE /api/auth/profile (self-deletion) and DELETE /api/auth/profile/:id (admin deletion) // Combined handler for both self-deletion and admin deletion of users func (h *AuthHandler) DeleteUser(c *echo.Context) error { currentUser := MustGetAuthenticatedUser(c) // Get target user ID from URL param (admin mode) or use current user (self-deletion) targetUserID := c.Param("id") var targetUserUUID pgtype.UUID if targetUserID != "" { // Admin deletion mode if currentUser.Role != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } parsedUUID, err := uuid.Parse(targetUserID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true} } else { // Self-deletion mode targetUserUUID = currentUser.ID } // Check if this is the last admin user - prevent deletion users, err := h.db.ListUsers(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"}) } // Count admin users and identify the user to be deleted adminCount := 0 targetUserRole := "" userFound := false for _, user := range users { if user.Role == "admin" { adminCount++ } // Find target user details if user.ID.Bytes == targetUserUUID.Bytes { targetUserRole = user.Role userFound = true // Can't break here - still need to count all admins for last admin check } } // Check if target user exists in the database if !userFound { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusNotFound, `
User not found
`) } return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) } // Prevent deletion if target user is admin and this is the last admin if targetUserRole == "admin" && adminCount == 1 { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
Cannot delete the last admin account
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last admin account"}) } // Delete user (this will cascade to delete all related data) err = h.db.DeleteUser(c.Request().Context(), targetUserUUID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusNotFound, `
User not found
`) } return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) } if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to delete account
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // Create success message based on context var message string if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes { message = "user account deleted successfully" } else { message = "account deleted successfully" } return c.JSON(http.StatusOK, map[string]string{"message": message}) } type UpdateUserMaxDevicesRequest struct { MaxDevices int32 `json:"max_devices" validate:"required,min=1,max=100"` } // UpdateUserMaxDevices handles PUT /api/auth/users/:id/max-devices (admin only) func (h *AuthHandler) UpdateUserMaxDevices(c *echo.Context) error { userID := c.Param("id") if userID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id required"}) } var req UpdateUserMaxDevicesRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } userUUID, err := uuid.Parse(userID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } user, err := h.db.UpdateUserMaxDevices(c.Request().Context(), database.UpdateUserMaxDevicesParams{ ID: pgtype.UUID{Bytes: userUUID, Valid: true}, MaxDevices: pgtype.Int4{Int32: req.MaxDevices, Valid: true}, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } _ = user // Suppress unused variable warning when sqlc returns user return c.JSON(http.StatusOK, map[string]string{"message": "max devices updated"}) } // AdminMiddleware checks if the user has admin role func AdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { userRole, exists := c.Get("user_role").(string) if !exists || userRole != "admin" { return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) } return next(c) } } func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, userUsername string) (string, error) { claims := jwt.MapClaims{ "jti": uuid.New().String(), "user_id": userID, "user_role": userRole, "user_email": userEmail, "user_username": userUsername, "exp": time.Now().Add(SessionDuration).Unix(), "iat": time.Now().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(h.jwtKey) } func (h *AuthHandler) generateJWTWithRole(userID, userRole string) (string, error) { return h.generateJWTWithAllClaims(userID, userRole, "", "") } func (h *AuthHandler) generateJWT(userID string) (string, error) { return h.generateJWTWithRole(userID, "user") } func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error { defaultCollections := []struct { Name string Description string Icon string Color string QueryType string Priority int32 }{ {"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1}, {"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2}, {"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3}, {"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4}, {"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5}, } for _, col := range defaultCollections { _, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{ UserID: userID, Name: col.Name, Description: pgtype.Text{String: col.Description, Valid: true}, Icon: pgtype.Text{String: col.Icon, Valid: true}, Color: pgtype.Text{String: col.Color, Valid: true}, ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, QueryType: pgtype.Text{String: col.QueryType, Valid: true}, Priority: pgtype.Int4{Int32: col.Priority, Valid: true}, }) if err != nil { return err } } return nil }