Files
bookhoard/internal/handlers/auth.go
T
john-okeefe 6398802d15 docs: Add package documentation for handlers and services
Add Go package documentation comments to clarify the purpose and scope of:

- internal/handlers/: HTTP request/response handlers for authentication,
  libraries, media items, reading, collections, dashboards, devices,
  analytics, and system features

- internal/services/: Core business logic layer including media scanning,
  library management, search, analytics, and conversion services

These doc comments improve code discoverability and help developers understand
the architectural separation between HTTP handling (handlers) and business
logic (services).
2026-04-13 09:23:12 -04:00

1018 lines
36 KiB
Go

// 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"
"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"`
}
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"`
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, `<div class="text-red-500">Invalid request</div>`)
}
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, `<div class="text-red-500">`+err.Error()+`</div>`)
}
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, `<div class="text-red-500">`+err.Error()+`</div>`)
}
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, `<div class="text-red-500">Username cannot be empty or whitespace</div>`)
}
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, `<div class="text-red-500">Email already exists</div>`)
}
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, `<div class="text-red-500">Username already exists</div>`)
}
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, `<div class="text-red-500">Failed to check existing users: `+err.Error()+`</div>`)
}
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, `<div class="text-red-500">Failed to hash password</div>`)
}
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, `<div class="text-red-500">Invalid role. Must be 'user' or 'admin'</div>`)
}
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, `<div class="text-red-500">Only existing administrators can create admin accounts</div>`)
}
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, `<div class="text-red-500">`+err.Error()+`</div>`)
}
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, `<div class="text-red-500">Failed to create default collections</div>`)
}
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, `<div class="text-red-500">Failed to generate token</div>`)
}
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(uuid.UUID(user.ID.Bytes))
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
}
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(`<div class="text-green-500">Registration successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('refreshToken', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '/dashboard';
</script>`, 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, `<div class="text-red-500">Invalid request</div>`)
}
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, `<div class="text-red-500">`+err.Error()+`</div>`)
}
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, `<div class="text-red-500">`+err.Error()+`</div>`)
}
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, `<div class="text-red-500">`+errMsg+`</div>`)
}
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, `<div class="text-red-500">`+errMsg+`</div>`)
}
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, `<div class="text-red-500">`+errMsg+`</div>`)
}
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, `<div class="text-red-500">Failed to generate token</div>`)
}
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(uuid.UUID(user.ID.Bytes))
if err != nil {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
}
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(`<div class="text-green-500">Login successful! Redirecting...</div>
<script>
localStorage.setItem('token', '%s');
localStorage.setItem('refreshToken', '%s');
localStorage.setItem('user', JSON.stringify(%s));
window.location.href = '%s';
</script>`, 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: [16]byte(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 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: [16]byte(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: [16]byte(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, `<div class="text-red-500">User not found</div>`)
}
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, `<div class="text-red-500">Cannot delete the last admin account</div>`)
}
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 err == pgx.ErrNoRows {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusNotFound, `<div class="text-red-500">User not found</div>`)
}
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, `<div class="text-red-500">Failed to delete account</div>`)
}
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 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},
}
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
}