Files
bookhoard/internal/handlers/auth.go
T
john-okeefe 457a38306d feat(auth): make session duration and password rules configurable
Replace the hardcoded 7-day session lifetime and fixed password
complexity rules with registry-backed accessors so they can be tuned
from the admin UI without a code change.

auth.go:
- Drop the SessionDuration const; keep DefaultSessionDuration (7 days)
  as the fallback used when no registry is wired (e.g. in tests).
- AuthHandler gains an optional *database.SettingsRegistry and a
  sessionDuration() helper that reads the registry, falling back to
  DefaultSessionDuration.
- Cookie MaxAge, JWT exp claim, and ExpiresIn responses now derive from
  sessionDuration() instead of the package-level SessionDurationSec, so
  a settings change takes effect on the next login.

refresh_token.go:
- Refresh-token lifetime follows sessionDuration() via a new
  refreshTokenTTL() helper (was a separate refreshTokenExpiration const
  that silently had to be kept in sync with the session duration).

password_validator.go:
- PasswordValidator now reads min length and the upper/lower/number/
  special toggles from the registry at validation time, so rule
  changes apply immediately. The special-character regex is compiled
  once and reused (sync.Once).
- GetPasswordRequirements() and ValidatePassword() reflect the active
  configured rules instead of a static list.
- Add SetDefaultPasswordSettings() so the package-level default
  validator (used by echo's struct-tag validator) follows live config.

All paths degrade gracefully to the historical defaults when no
registry is wired.
2026-08-10 08:01:11 -04:00

1086 lines
38 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"
"bookhoard/internal/setupstatus"
"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 (
// DefaultSessionDuration is the fallback session duration used when no
// settings registry is wired (matches the historical 7-day value).
DefaultSessionDuration = 7 * 24 * time.Hour
)
// SessionDurationSec is retained for backward compatibility; new code uses the
// registry via AuthHandler.sessionDuration().
var SessionDurationSec = int(DefaultSessionDuration.Seconds())
// SetSettings wires the tunable settings registry (optional).
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
// sessionDuration returns the active session duration from the registry.
func (h *AuthHandler) sessionDuration() time.Duration {
if h.settings != nil {
return h.settings.SessionDuration()
}
return DefaultSessionDuration
}
// refreshTokenTTL returns the active refresh-token lifetime (shared with the
// session duration), with a compiled-default fallback.
func (h *AuthHandler) refreshTokenTTL() time.Duration {
if h.settings != nil {
return h.settings.SessionDuration()
}
return DefaultSessionDuration
}
var secure = os.Getenv("COOKIE_SECURE")
type AuthHandler struct {
db *database.Queries
jwtKey []byte
loginAttemptTracker *middleware.LoginAttemptTracker
settings *database.SettingsRegistry
}
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" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
}
type AdminUpdateUserRequest struct {
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" form:"role" 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 !adminExists {
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()})
}
// A new user may have changed the admin count (e.g. first user becomes
// admin), so refresh the setup-status cache.
setupstatus.Invalidate()
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: int(h.sessionDuration().Seconds()),
}
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, `<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: int(h.sessionDuration().Seconds()),
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: int(h.sessionDuration().Seconds()),
}
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, `<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: int(h.sessionDuration().Seconds()),
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()})
}
// Role changes can affect the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
}
// 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" form:"current_password"`
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" form:"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, `<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"})
}
// If deleting an admin, reassign their libraries and media items to another admin
// before deletion to prevent ON DELETE SET NULL from orphaning ownership
if targetUserRole == "admin" {
successor, err := h.db.GetFirstAdminExclude(c.Request().Context(), targetUserUUID)
if err == nil {
_ = h.db.ReassignLibraries(c.Request().Context(), database.ReassignLibrariesParams{
CreatedByAdminID: targetUserUUID,
CreatedByAdminID_2: successor.ID,
})
_ = h.db.ReassignMediaItems(c.Request().Context(), database.ReassignMediaItemsParams{
AddedByAdminID: targetUserUUID,
AddedByAdminID_2: successor.ID,
})
}
}
// 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, `<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()})
}
// Deletion may have changed the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
// 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(h.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
}