Files
bookhoard/internal/handlers/auth.go
T
John O'Keefe e3ca3426ba fix(auth): emit default_audio_speed unconditionally on the profile response
TestDocsMatchRoutes failed on users/get_profile.md: the example carries
default_audio_speed but the response struct tagged it omitempty, so the
gate's zero-value marshal never sees the key and reads the doc as
inventing it. The tag was semantically dead: the column is NOT NULL
DEFAULT 1.0 (schema.sql 44) and writes validate 0.25-4, so a stored 0
is out of domain and every real profile emits the field. Response-only
struct, no form-bind impact. The key was added to the doc example in
ef827a3a without a gate run - this is exactly the class of drift the
gate exists to catch; make test now passes again.
2026-11-02 08:56:37 -05:00

1237 lines
46 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/rls"
"bookhoard/internal/setupstatus"
"context"
"errors"
"fmt"
"html"
"html/template"
"math"
"net/http"
"os"
"strconv"
"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 }
// defaultTimezone returns the system-wide default timezone for new users.
// Falls back to UTC when no registry is wired, the setting is empty, or
// the value is not a valid IANA location.
func (h *AuthHandler) defaultTimezone() string {
if h.settings == nil {
return "UTC"
}
tz := h.settings.DefaultTimezone()
if tz == "" {
return "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return "UTC"
}
return tz
}
// 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"`
Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits.
// Response-only struct: omit nothing — default_audio_speed is NOT NULL
// DEFAULT 1.0 (writes validate 0.25–4), so a real profile always emits
// it; omitempty here would drop a contract field from the docs gate.
DefaultAudioSpeed float64 `json:"default_audio_speed"` // Whether the web reader/player should hold a Screen Wake Lock.
KeepScreenAwake bool `json:"keep_screen_awake"`
// Resume rewind: seconds before the saved position the audio
// player starts (open-resume and every genuine unpause). 0 is a
// real value (exact resume) — never omitted.
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
}
type ProfileUpdateRequest 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"`
// "true"/"false" — the profile dropdown always sends it; JSON clients
// that omit it decode as "" and leave the column untouched.
KeepScreenAwake string `json:"keep_screen_awake,omitempty" form:"keep_screen_awake" validate:"omitempty,oneof=true false"`
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
// Resume rewind seconds, as the form select sends it ("0"/"5"/"10").
// The string shape (keep_screen_awake precedent) lets the form
// select an explicit 0=Off while JSON clients that omit it decode
// as "" and leave the column untouched.
AudioResumeRewind string `json:"audio_resume_rewind_seconds,omitempty" form:"audio_resume_rewind_seconds" validate:"omitempty,oneof=0 5 10"`
}
// 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)
}
// Public registration can be disabled by admins. The giveaway exception
// is an unconfigured server: with zero admins the first-user-becomes-
// admin bootstrap path must stay reachable (mirrors setupRedirect-
// Middleware's "users exist" reasoning). Admin account creation via the
// admin API never rides this route, so it is unaffected regardless.
if h.settings != nil && !h.settings.RegistrationEnabled() {
count, countErr := h.db.CountAdmins(c.Request().Context())
if countErr != nil || count > 0 {
if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Registration is disabled on this server</div>`)
}
return c.JSON(http.StatusForbidden, map[string]string{"error": "registration is disabled on this server"})
}
}
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,
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
})
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" {
// CSP: see the login handler — data attributes, not inline scripts.
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="/dashboard">Registration successful! Redirecting...</div>`,
accessToken)
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
// invalidCredentialsHTML is the htmx error partial for failed logins;
// web/src/auth_forms.ts swaps 4xx/5xx HTML responses into the form target.
const invalidCredentialsHTML = `<div class="text-red-500">Invalid email/username or password.</div>`
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.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
}
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.HTML(http.StatusUnauthorized, invalidCredentialsHTML)
}
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" {
// CSP: hash-mode script-src refuses inline scripts, so the htmx
// partial carries data attributes instead; web/src/auth_forms.ts
// stashes the token and navigates on htmx:afterSwap. The redirect
// target is path-only (open-redirect hygiene) and the old
// 'user'/'refreshToken' localStorage writes are gone — nothing
// ever read them.
redirect := c.FormValue("redirect")
if redirect == "" || !strings.HasPrefix(redirect, "/") {
redirect = "/dashboard"
}
html := fmt.Sprintf(`<div class="text-green-500" data-auth-token="%s" data-redirect="%s">Login successful! Redirecting...</div>`,
accessToken, html.EscapeString(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 {
ctxUser := MustGetAuthenticatedUser(c)
// The context user is a JWT-claims stub (id/email/username/role) —
// it carries no theme. Read the full row: clients (the Android app)
// sync their app-chrome theme from this field.
user, err := h.db.GetUser(c.Request().Context(), ctxUser.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load profile"})
}
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,
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
KeepScreenAwake: user.KeepScreenAwake,
AudioResumeRewind: int(user.AudioResumeRewindSeconds),
})
}
// 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 ProfileUpdateRequest
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()})
}
}
// Update the default audiobook playback speed (plan §8). The form
// select always sends it; JSON clients that omit it decode as 0 and
// leave the column untouched.
if req.DefaultAudioSpeed != 0 {
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
}
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
ID: targetUserUUID,
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Resume-rewind preference. The form select always sends it
// ("0"/"5"/"10"); JSON clients that omit it decode as "" and leave
// the column untouched. Strict allow-list — the three choices the
// UI offers are the only values the API accepts.
if req.AudioResumeRewind != "" {
rewind, err := strconv.Atoi(req.AudioResumeRewind)
if err != nil || (rewind != 0 && rewind != 5 && rewind != 10) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "audio_resume_rewind_seconds must be 0, 5, or 10"})
}
err = h.db.UpdateUserAudioResumeRewind(c.Request().Context(), database.UpdateUserAudioResumeRewindParams{
ID: targetUserUUID,
AudioResumeRewindSeconds: int32(rewind),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Keep-screen-awake preference. The form dropdown always sends it
// ("true"/"false"); JSON clients that omit it decode as "" and leave
// the column untouched.
if req.KeepScreenAwake != "" {
if req.KeepScreenAwake != "true" && req.KeepScreenAwake != "false" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_screen_awake must be 'true' or 'false'"})
}
err := h.db.UpdateUserKeepScreenAwake(c.Request().Context(), database.UpdateUserKeepScreenAwakeParams{
ID: targetUserUUID,
KeepScreenAwake: req.KeepScreenAwake == "true",
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Browser path (htmx modal + profile page): refresh so every rendered
// value (timezone select, table rows, header) reflects the save and the
// modal closes. A bare JSON body swapped into #profile-result read as
// "nothing happened" — the save DID land, invisibly.
if c.Request().Header.Get("HX-Request") == "true" {
c.Response().Header().Set("HX-Refresh", "true")
return c.HTML(http.StatusOK, `<div class="text-green-500">Profile updated.</div>`)
}
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)
}
// GetPasswordRules handles GET /api/auth/password-rules (public — the
// register page is pre-auth). Exposes the ACTIVE complexity configuration
// so the UI requirement checkers mirror the real rules instead of
// hardcoding defaults. The server remains the enforcer.
func (h *AuthHandler) GetPasswordRules(c *echo.Context) error {
minLength, upper, lower, number, special := 8, true, true, true, true
if h.settings != nil {
r := h.settings.PasswordRules()
minLength, upper, lower, number, special =
r.MinLength, r.Upper, r.Lower, r.Number, r.Special
}
if minLength < 1 {
minLength = 8
}
return c.JSON(http.StatusOK, map[string]interface{}{
"min_length": minLength,
"require_upper": upper,
"require_lower": lower,
"require_number": number,
"require_special": special,
})
}
// AdminCreateUser handles POST /api/auth/users (AdminMiddleware-gated).
// The admin's counterpart to public registration: creating an account for
// someone else must work even when self-service registration is disabled
// (the family/self-hosted default). The requester's admin role comes from
// the route group, and the new account NEVER auto-logins — no tokens are
// issued here, unlike Register.
func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
var req RegisterRequest
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()})
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"})
}
role := strings.ToLower(req.Role)
if role == "" {
role = "user"
}
if role != "user" && role != "admin" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
}
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
}
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
}
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: role,
Timezone: pgtype.Text{String: h.defaultTimezone(), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
}
// The admin UI posts this via HTMX: refresh the page so the new user
// appears in the table, and confirm inline.
if c.Request().Header.Get("HX-Request") == "true" {
c.Response().Header().Set("HX-Refresh", "true")
return c.HTML(http.StatusCreated, `<div class="text-green-500">User "`+template.HTMLEscapeString(user.Username)+`" created.</div>`)
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(user.ID.Bytes).String(),
"email": user.Email,
"username": user.Username,
"role": user.Role,
"timezone": user.Timezone,
"created_at": user.CreatedAt,
})
}
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 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 {
// Registration runs BEFORE the JWT exists — the request context
// carries no identity, and the collections RLS insert policy
// (2026-10-31) would reject the five system-collection seeds.
// Declare the new user as the actor: owner == actor, policy passes.
ctx = rls.WithUser(ctx, userID)
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 {
// Idempotent by the STABLE identity (user + query_type): a
// preserved or renamed row heals to canonical metadata instead of
// duplicating (the naming-drift bug). Insert only when absent.
existing, err := h.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
UserID: userID,
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
})
if err == nil {
if _, uerr := h.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
ID: existing.ID,
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},
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
}); uerr != nil {
return uerr
}
continue
}
_, 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
}