The timezone update block in UpdateProfile() referenced undefined variables ctx and userUUID, causing a compile error. Fixed to use c.Request().Context() and targetUserUUID which are the correct variables in that handler scope. Also added Timezone field to AdminUpdateUserRequest struct so the timezone value is properly bound from JSON requests, since UpdateProfile() binds to AdminUpdateUserRequest rather than UpdateProfileRequest.
1037 lines
36 KiB
Go
1037 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"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
// Session duration constants
|
|
// Follows same pattern as refresh_token.go
|
|
SessionDuration = 7 * 24 * time.Hour // 7 days
|
|
)
|
|
|
|
// SessionDurationSec is the session duration in seconds for use in cookies and API responses
|
|
// Note: This is computed from SessionDuration to avoid magic numbers
|
|
var SessionDurationSec = int(SessionDuration.Seconds())
|
|
|
|
var secure = os.Getenv("COOKIE_SECURE")
|
|
|
|
type AuthHandler struct {
|
|
db *database.Queries
|
|
jwtKey []byte
|
|
loginAttemptTracker *middleware.LoginAttemptTracker
|
|
}
|
|
|
|
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
|
return &AuthHandler{
|
|
db: db,
|
|
jwtKey: []byte(jwtSecret),
|
|
loginAttemptTracker: loginAttemptTracker,
|
|
}
|
|
}
|
|
|
|
type RegisterRequest struct {
|
|
Email string `form:"email" json:"email" validate:"required,email"`
|
|
Username string `form:"username" json:"username" validate:"required,min=3,max=50"`
|
|
Password string `form:"password" json:"password" validate:"required,passwordcomplex"`
|
|
FirstName string `form:"first_name" json:"first_name,omitempty"`
|
|
LastName string `form:"last_name" json:"last_name,omitempty"`
|
|
Role string `form:"role" json:"role,omitempty"`
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Login string `form:"login" json:"login" validate:"required"` // email or username
|
|
Password string `form:"password" json:"password" validate:"required"`
|
|
}
|
|
|
|
type AuthResponse struct {
|
|
Token string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
User UserProfile `json:"user"`
|
|
}
|
|
|
|
type UserProfile struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
type UpdateProfileRequest struct {
|
|
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
|
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
|
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
|
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
|
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
|
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
|
}
|
|
|
|
type AdminUpdateUserRequest struct {
|
|
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
|
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
|
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
|
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
|
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
|
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
|
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
|
}
|
|
|
|
// Register handles POST /api/auth/register
|
|
func (h *AuthHandler) Register(c *echo.Context) error {
|
|
email := c.FormValue("email")
|
|
username := c.FormValue("username")
|
|
password := c.FormValue("password")
|
|
firstName := c.FormValue("first_name")
|
|
lastName := c.FormValue("last_name")
|
|
role := c.FormValue("role")
|
|
|
|
if email == "" || username == "" || password == "" {
|
|
req := RegisterRequest{}
|
|
if err := c.Bind(&req); err != nil {
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return c.HTML(http.StatusBadRequest, `<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(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(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: parsedUUID, Valid: true}
|
|
} else {
|
|
// Self-edit mode
|
|
targetUserUUID = currentUser.ID
|
|
}
|
|
|
|
var req AdminUpdateUserRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Handle role change (admin mode only)
|
|
if req.Role != "" && isAdminMode {
|
|
if req.Role != "admin" && req.Role != "user" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'admin' or 'user'"})
|
|
}
|
|
|
|
// Check if this is the last admin (preventing demotion)
|
|
users, err := h.db.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
|
|
}
|
|
|
|
adminCount := 0
|
|
currentTargetUserRole := ""
|
|
for _, user := range users {
|
|
if user.Role == "admin" {
|
|
adminCount++
|
|
}
|
|
if user.ID.Bytes == targetUserUUID.Bytes {
|
|
currentTargetUserRole = user.Role
|
|
}
|
|
}
|
|
|
|
// Prevent demoting the last admin
|
|
if currentTargetUserRole == "admin" && req.Role == "user" && adminCount == 1 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot demote the last admin"})
|
|
}
|
|
|
|
// Update role
|
|
_, err = h.db.UpdateUserRole(c.Request().Context(), database.UpdateUserRoleParams{
|
|
ID: targetUserUUID,
|
|
Role: req.Role,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
// Update username (if provided)
|
|
if req.Username != "" {
|
|
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
|
|
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
|
|
}
|
|
|
|
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
|
|
ID: targetUserUUID,
|
|
Username: req.Username,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
// Update timezone
|
|
if req.Timezone != "" {
|
|
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid timezone",
|
|
})
|
|
}
|
|
err := h.db.UpdateUserTimezone(c.Request().Context(), database.UpdateUserTimezoneParams{
|
|
ID: targetUserUUID,
|
|
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Update email (if provided)
|
|
if req.Email != "" {
|
|
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
|
if err == nil && existingUser.ID.Bytes != targetUserUUID.Bytes {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
|
|
}
|
|
|
|
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
|
|
ID: targetUserUUID,
|
|
Email: req.Email,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
// Update first/last name (if provided)
|
|
if req.FirstName != "" || req.LastName != "" {
|
|
err := h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
|
|
ID: targetUserUUID,
|
|
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
|
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
// Update theme (if provided)
|
|
if req.Theme != "" {
|
|
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
|
|
ID: targetUserUUID,
|
|
Theme: pgtype.Text{String: req.Theme, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated successfully"})
|
|
}
|
|
|
|
// ListUsers handles GET /api/auth/users
|
|
func (h *AuthHandler) ListUsers(c *echo.Context) error {
|
|
users, err := h.db.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, users)
|
|
}
|
|
|
|
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
|
|
func normalizePath(path string) string {
|
|
fmt.Printf("normalizePath input: '%s'\n", path)
|
|
|
|
var cleaned string
|
|
// Handle home directory expansion (~)
|
|
if strings.HasPrefix(path, "~/") {
|
|
// Keep the original path for ~ to preserve user's formatting
|
|
// Just normalize separators and that's it
|
|
cleaned = strings.ReplaceAll(path, "\\", "/")
|
|
} else {
|
|
// Clean the path to remove redundant separators, ., .. etc.
|
|
cleaned = filepath.Clean(path)
|
|
// Convert to consistent path separators (use forward slashes for storage)
|
|
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
|
|
// Remove trailing slash unless it's root path
|
|
if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") {
|
|
cleaned = strings.TrimSuffix(cleaned, "/")
|
|
}
|
|
}
|
|
|
|
fmt.Printf("normalizePath output: '%s'\n", cleaned)
|
|
return cleaned
|
|
}
|
|
|
|
type UpdateThemeRequest struct {
|
|
Theme string `json:"theme" validate:"required"`
|
|
}
|
|
|
|
// UpdateTheme handles PUT /api/auth/theme
|
|
func (h *AuthHandler) UpdateTheme(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
var req UpdateThemeRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
err := h.db.UpdateUserTheme(c.Request().Context(), database.UpdateUserThemeParams{
|
|
ID: user.ID,
|
|
Theme: pgtype.Text{String: req.Theme, Valid: req.Theme != ""},
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "theme updated successfully"})
|
|
}
|
|
|
|
type UpdateUsernameRequest struct {
|
|
Username string `json:"username" validate:"required,min=3,max=50"`
|
|
}
|
|
|
|
// UpdateUsername handles PUT /api/auth/username
|
|
func (h *AuthHandler) UpdateUsername(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
var req UpdateUsernameRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Check if username is already taken by another user
|
|
existingUser, err := h.db.GetUserByUsername(c.Request().Context(), req.Username)
|
|
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
|
|
}
|
|
|
|
// Update username
|
|
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
|
|
ID: user.ID,
|
|
Username: req.Username,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "username updated successfully"})
|
|
}
|
|
|
|
type UpdateEmailRequest struct {
|
|
Email string `json:"email" validate:"required,email"`
|
|
}
|
|
|
|
// UpdateEmail handles PUT /api/auth/email
|
|
func (h *AuthHandler) UpdateEmail(c *echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
var req UpdateEmailRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Check if email is already taken by another user
|
|
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
|
if err == nil && existingUser.ID.Bytes != user.ID.Bytes {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
|
|
}
|
|
|
|
// Update email
|
|
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
|
|
ID: user.ID,
|
|
Email: req.Email,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "email updated successfully"})
|
|
}
|
|
|
|
type UpdatePasswordRequest struct {
|
|
CurrentPassword string `json:"current_password" validate:"required"`
|
|
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
|
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
|
}
|
|
|
|
// UpdatePassword handles PUT /api/auth/password (self-change) and PUT /api/auth/password/:id (admin reset)
|
|
// Combined handler for both self-service password change and admin password reset
|
|
func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
|
currentUser := MustGetAuthenticatedUser(c)
|
|
|
|
// Determine target user: URL param (admin mode) or current user (self-change)
|
|
targetUserID := c.Param("id")
|
|
var targetUserUUID pgtype.UUID
|
|
isAdminMode := targetUserID != ""
|
|
|
|
if isAdminMode {
|
|
// Admin resetting another user's password - validate admin role
|
|
if currentUser.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
parsedUUID, err := uuid.Parse(targetUserID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
|
} else {
|
|
// Self-change mode
|
|
targetUserUUID = currentUser.ID
|
|
}
|
|
|
|
type PasswordRequest struct {
|
|
CurrentPassword string `json:"current_password,omitempty"`
|
|
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
|
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
|
}
|
|
|
|
var req PasswordRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Self-change mode: require current password
|
|
if !isAdminMode {
|
|
if req.CurrentPassword == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "current password required"})
|
|
}
|
|
|
|
// Get current password hash
|
|
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), targetUserUUID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "user not found"})
|
|
}
|
|
|
|
// Verify current password
|
|
err = bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword))
|
|
if err != nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
|
|
}
|
|
}
|
|
|
|
// Validate new password matches confirmation
|
|
if req.NewPassword != req.ConfirmPassword {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "passwords do not match"})
|
|
}
|
|
|
|
// Hash new password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
|
}
|
|
|
|
// Update password
|
|
err = h.db.UpdatePassword(c.Request().Context(), database.UpdatePasswordParams{
|
|
ID: targetUserUUID,
|
|
PasswordHash: string(hashedPassword),
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "password updated successfully"})
|
|
}
|
|
|
|
// DeleteUser handles DELETE /api/auth/profile (self-deletion) and DELETE /api/auth/profile/:id (admin deletion)
|
|
// Combined handler for both self-deletion and admin deletion of users
|
|
func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
|
currentUser := MustGetAuthenticatedUser(c)
|
|
|
|
// Get target user ID from URL param (admin mode) or use current user (self-deletion)
|
|
targetUserID := c.Param("id")
|
|
var targetUserUUID pgtype.UUID
|
|
|
|
if targetUserID != "" {
|
|
// Admin deletion mode
|
|
if currentUser.Role != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
parsedUUID, err := uuid.Parse(targetUserID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
|
} else {
|
|
// Self-deletion mode
|
|
targetUserUUID = currentUser.ID
|
|
}
|
|
|
|
// Check if this is the last admin user - prevent deletion
|
|
users, err := h.db.ListUsers(c.Request().Context())
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check existing users"})
|
|
}
|
|
|
|
// Count admin users and identify the user to be deleted
|
|
adminCount := 0
|
|
targetUserRole := ""
|
|
userFound := false
|
|
|
|
for _, user := range users {
|
|
if user.Role == "admin" {
|
|
adminCount++
|
|
}
|
|
// Find target user details
|
|
if user.ID.Bytes == targetUserUUID.Bytes {
|
|
targetUserRole = user.Role
|
|
userFound = true
|
|
// Can't break here - still need to count all admins for last admin check
|
|
}
|
|
}
|
|
|
|
// Check if target user exists in the database
|
|
if !userFound {
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return c.HTML(http.StatusNotFound, `<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 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()})
|
|
}
|
|
|
|
// Create success message based on context
|
|
var message string
|
|
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
|
message = "user account deleted successfully"
|
|
} else {
|
|
message = "account deleted successfully"
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": message})
|
|
}
|
|
|
|
type UpdateUserMaxDevicesRequest struct {
|
|
MaxDevices int32 `json:"max_devices" validate:"required,min=1,max=100"`
|
|
}
|
|
|
|
// UpdateUserMaxDevices handles PUT /api/auth/users/:id/max-devices (admin only)
|
|
func (h *AuthHandler) UpdateUserMaxDevices(c *echo.Context) error {
|
|
userID := c.Param("id")
|
|
if userID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "user id required"})
|
|
}
|
|
|
|
var req UpdateUserMaxDevicesRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
user, err := h.db.UpdateUserMaxDevices(c.Request().Context(), database.UpdateUserMaxDevicesParams{
|
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
MaxDevices: pgtype.Int4{Int32: req.MaxDevices, Valid: true},
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
_ = user // Suppress unused variable warning when sqlc returns user
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "max devices updated"})
|
|
}
|
|
|
|
// AdminMiddleware checks if the user has admin role
|
|
func AdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c *echo.Context) error {
|
|
userRole, exists := c.Get("user_role").(string)
|
|
if !exists || userRole != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
return next(c)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, userUsername string) (string, error) {
|
|
claims := jwt.MapClaims{
|
|
"jti": uuid.New().String(),
|
|
"user_id": userID,
|
|
"user_role": userRole,
|
|
"user_email": userEmail,
|
|
"user_username": userUsername,
|
|
"exp": time.Now().Add(SessionDuration).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(h.jwtKey)
|
|
}
|
|
|
|
func (h *AuthHandler) generateJWTWithRole(userID, userRole string) (string, error) {
|
|
return h.generateJWTWithAllClaims(userID, userRole, "", "")
|
|
}
|
|
|
|
func (h *AuthHandler) generateJWT(userID string) (string, error) {
|
|
return h.generateJWTWithRole(userID, "user")
|
|
}
|
|
|
|
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
|
|
defaultCollections := []struct {
|
|
Name string
|
|
Description string
|
|
Icon string
|
|
Color string
|
|
QueryType string
|
|
Priority int32
|
|
}{
|
|
{"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
|
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
|
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
|
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
|
}
|
|
|
|
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
|
|
}
|