- Fix database authentication error by exposing actual database error messages - Update error handling to follow pgx v5 standards with detailed error reporting - Restore token environment variable management in Register User.bru for subsequent requests - Enable proper debugging of database connection issues during user registration The registration API now provides detailed error messages instead of generic 'failed to check existing users' when database connection or authentication fails, making debugging easier.
863 lines
30 KiB
Go
863 lines
30 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookmann/internal/database"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
jwt "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/v4"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
db *database.Queries
|
|
jwtKey []byte
|
|
}
|
|
|
|
func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler {
|
|
return &AuthHandler{
|
|
db: db,
|
|
jwtKey: []byte(jwtSecret),
|
|
}
|
|
}
|
|
|
|
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,min=6"`
|
|
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:"token"`
|
|
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 {
|
|
FirstName string `json:"first_name,omitempty"`
|
|
LastName string `json:"last_name,omitempty"`
|
|
}
|
|
|
|
// Register handles POST /api/auth/register
|
|
func (h *AuthHandler) Register(c echo.Context) error {
|
|
// Try form data first (HTMX), then JSON (Bruno)
|
|
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 == "" {
|
|
// Fallback to JSON binding
|
|
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()})
|
|
}
|
|
|
|
// Check if user already exists
|
|
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"})
|
|
}
|
|
|
|
// Check if this is the first user - if so, make them admin regardless of request
|
|
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()})
|
|
}
|
|
|
|
// Hash password
|
|
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"})
|
|
}
|
|
|
|
// Check if any admin users already exist
|
|
adminExists := false
|
|
for _, u := range users {
|
|
if u.Role == "admin" {
|
|
adminExists = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Set role - first user is always admin, otherwise validate requested role based on existing admins
|
|
var userRole string
|
|
if len(users) == 0 {
|
|
userRole = "admin" // First user is always admin
|
|
} else {
|
|
userRole = req.Role
|
|
if userRole == "" {
|
|
userRole = "user" // Default to regular user if not specified
|
|
}
|
|
|
|
// Validate role for subsequent users
|
|
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'"})
|
|
}
|
|
|
|
// Role-based restrictions: only admins can create admin users if any admin already exists
|
|
if userRole == "admin" && adminExists {
|
|
// Check if current user is admin (requires authentication)
|
|
userID := c.Get("user_id")
|
|
if userID == nil {
|
|
// Not authenticated - cannot create admin user if admins exist
|
|
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 existing administrators can create admin accounts"})
|
|
}
|
|
|
|
// User is authenticated - check their role
|
|
userRoleAuth := c.Get("user_role").(string)
|
|
if userRoleAuth != "admin" {
|
|
// Authenticated but not admin - cannot create admin accounts
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Only administrators can create admin accounts</div>`)
|
|
}
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only administrators can create admin accounts"})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create user
|
|
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}, // default theme
|
|
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()})
|
|
}
|
|
|
|
// Generate JWT with user details
|
|
token, 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"})
|
|
}
|
|
|
|
// Check if request is from HTMX
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
// Return HTML with script to set token and redirect
|
|
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
|
|
<script>
|
|
localStorage.setItem('token', '%s');
|
|
localStorage.setItem('user', JSON.stringify(%s));
|
|
document.cookie = 'token=%s; path=/; max-age=86400';
|
|
window.location.href = '/api/dashboard';
|
|
</script>`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username), token)
|
|
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: token,
|
|
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 {
|
|
// Debug logging
|
|
fmt.Printf("Login request - Content-Type: %s\n", c.Request().Header.Get("Content-Type"))
|
|
fmt.Printf("Form values - login: %s, password: %s\n", c.FormValue("login"), c.FormValue("password"))
|
|
|
|
// Try form data first (HTMX), then JSON (Bruno)
|
|
login := c.FormValue("login")
|
|
password := c.FormValue("password")
|
|
|
|
if login == "" || password == "" {
|
|
fmt.Printf("Form values empty, trying JSON bind\n")
|
|
// Fallback to JSON binding
|
|
req := LoginRequest{}
|
|
if err := c.Bind(&req); err != nil {
|
|
fmt.Printf("JSON bind error: %v\n", err)
|
|
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 {
|
|
fmt.Printf("Validation error: %v\n", err)
|
|
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
|
|
fmt.Printf("JSON bind success - login: %s\n", login)
|
|
}
|
|
|
|
req := LoginRequest{Login: login, Password: password}
|
|
if err := c.Validate(&req); err != nil {
|
|
fmt.Printf("Final validation error: %v\n", err)
|
|
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()})
|
|
}
|
|
|
|
// Get user by email or username (includes password hash for verification)
|
|
user, err := h.db.GetUserForLogin(c.Request().Context(), req.Login)
|
|
if err != nil {
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
|
|
}
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
|
}
|
|
|
|
// Check password
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
|
|
}
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
|
}
|
|
|
|
// Generate JWT with user details
|
|
token, 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"})
|
|
}
|
|
|
|
// Check if request is from HTMX
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
// Return HTML with script to set token and redirect
|
|
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
|
|
<script>
|
|
localStorage.setItem('token', '%s');
|
|
localStorage.setItem('user', JSON.stringify(%s));
|
|
document.cookie = 'token=%s; path=/; max-age=86400';
|
|
window.location.href = '/api/dashboard';
|
|
</script>`, token, 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), token)
|
|
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: token,
|
|
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 {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
user, err := h.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
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
|
|
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
var req UpdateProfileRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
err = h.db.UpdateUserProfile(c.Request().Context(), database.UpdateUserProfileParams{
|
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
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()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "profile updated"})
|
|
}
|
|
|
|
// 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()})
|
|
}
|
|
|
|
type UserList struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Theme string `json:"theme"`
|
|
Role string `json:"role"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
var userList []UserList
|
|
for _, u := range users {
|
|
theme := ""
|
|
if u.Theme.Valid {
|
|
theme = u.Theme.String
|
|
}
|
|
firstName := ""
|
|
if u.FirstName.Valid {
|
|
firstName = u.FirstName.String
|
|
}
|
|
lastName := ""
|
|
if u.LastName.Valid {
|
|
lastName = u.LastName.String
|
|
}
|
|
createdAt := ""
|
|
if u.CreatedAt.Valid {
|
|
createdAt = u.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
updatedAt := ""
|
|
if u.UpdatedAt.Valid {
|
|
updatedAt = u.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00")
|
|
}
|
|
userList = append(userList, UserList{
|
|
ID: uuid.UUID(u.ID.Bytes).String(),
|
|
Email: u.Email,
|
|
Username: u.Username,
|
|
FirstName: firstName,
|
|
LastName: lastName,
|
|
Theme: theme,
|
|
Role: u.Role,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, userList)
|
|
}
|
|
|
|
type AddEbookFolderRequest struct {
|
|
FolderPath string `json:"folder_path" validate:"required"`
|
|
}
|
|
|
|
// 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 EbookFolderResponse struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
FolderPath string `json:"folder_path"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type DeleteEbookFolderRequest struct {
|
|
FolderPath string `json:"folder_path" validate:"required"`
|
|
}
|
|
|
|
// AddEbookFolder handles POST /api/auth/ebook-folders (DEPRECATED - use libraries instead)
|
|
func (h *AuthHandler) AddEbookFolder(c echo.Context) error {
|
|
return c.JSON(http.StatusGone, map[string]string{"error": "Ebook folders are deprecated. Please use the library system instead."})
|
|
}
|
|
|
|
// GetEbookFolders handles GET /api/auth/ebook-folders (DEPRECATED - use libraries instead)
|
|
func (h *AuthHandler) GetEbookFolders(c echo.Context) error {
|
|
return c.JSON(http.StatusGone, map[string]string{"error": "Ebook folders are deprecated. Please use library system instead."})
|
|
}
|
|
|
|
// DeleteEbookFolder handles DELETE /api/auth/ebook-folders (DEPRECATED - use libraries instead)
|
|
func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
|
|
return c.JSON(http.StatusGone, map[string]string{"error": "Ebook folders are deprecated. Please use library system instead."})
|
|
}
|
|
|
|
type UpdateThemeRequest struct {
|
|
Theme string `json:"theme" validate:"required"`
|
|
}
|
|
|
|
// UpdateTheme handles PUT /api/auth/theme
|
|
func (h *AuthHandler) UpdateTheme(c echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
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: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
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 {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
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 && uuid.UUID(existingUser.ID.Bytes) != userUUID {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "username already taken"})
|
|
}
|
|
|
|
// Update username
|
|
err = h.db.UpdateUsername(c.Request().Context(), database.UpdateUsernameParams{
|
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
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 {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
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 && uuid.UUID(existingUser.ID.Bytes) != userUUID {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "email already taken"})
|
|
}
|
|
|
|
// Update email
|
|
err = h.db.UpdateEmail(c.Request().Context(), database.UpdateEmailParams{
|
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
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,min=6"`
|
|
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
|
}
|
|
|
|
// UpdatePassword handles PUT /api/auth/password
|
|
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
var req UpdatePasswordRequest
|
|
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 new passwords match
|
|
if req.NewPassword != req.ConfirmPassword {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "new passwords do not match"})
|
|
}
|
|
|
|
// Get current user's password hash
|
|
passwordHash, err := h.db.GetUserPasswordHash(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user"})
|
|
}
|
|
|
|
// Verify current password
|
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
|
|
}
|
|
|
|
// 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: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
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"})
|
|
}
|
|
|
|
// DeleteAccount handles DELETE /api/auth/account
|
|
// Supports self-deletion or admin deletion of other users
|
|
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
|
|
// Get target user ID from query parameter (for admin override) or use current user
|
|
targetUserID := c.QueryParam("user_id")
|
|
userID := c.Get("user_id").(string)
|
|
|
|
// If admin override is used, validate admin and use target
|
|
if targetUserID != "" {
|
|
// Admin override mode - check if current user is admin
|
|
userRole := c.Get("user_role").(string)
|
|
if userRole != "admin" {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
|
}
|
|
userID = targetUserID
|
|
}
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user 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 := ""
|
|
for _, user := range users {
|
|
if user.Role == "admin" {
|
|
adminCount++
|
|
}
|
|
// Find target user details
|
|
userUUIDStr := uuid.UUID(user.ID.Bytes).String()
|
|
if userUUIDStr == userID {
|
|
targetUserRole = user.Role
|
|
}
|
|
}
|
|
|
|
// 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(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return c.HTML(http.StatusNotFound, `<div class="text-red-500">User not found</div>`)
|
|
}
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
}
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to delete account</div>`)
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Create success message based on context
|
|
var message string
|
|
if targetUserID != "" && userID != c.Get("user_id").(string) {
|
|
message = "user account deleted successfully"
|
|
} else {
|
|
message = "account deleted successfully"
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": message})
|
|
}
|
|
|
|
type UpdateScanSettingsRequest struct {
|
|
ScanFrequencyMinutes int32 `json:"scan_frequency_minutes" validate:"required,min=15,max=1440"`
|
|
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
|
}
|
|
|
|
// UpdateScanSettings handles PUT /api/library/scan-settings
|
|
func (h *AuthHandler) UpdateScanSettings(c echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
var req UpdateScanSettingsRequest
|
|
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.UpdateScanSettings(c.Request().Context(), database.UpdateScanSettingsParams{
|
|
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
ScanFrequencyMinutes: pgtype.Int4{Int32: req.ScanFrequencyMinutes, Valid: true},
|
|
AutoScanEnabled: pgtype.Bool{Bool: req.AutoScanEnabled, 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": "scan settings updated successfully"})
|
|
}
|
|
|
|
// GetScanSettings handles GET /api/library/scan-settings
|
|
func (h *AuthHandler) GetScanSettings(c echo.Context) error {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
|
}
|
|
|
|
settings, err := h.db.GetScanSettings(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
// If no settings found, return defaults
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"scan_frequency_minutes": 60,
|
|
"auto_scan_enabled": true,
|
|
})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"scan_frequency_minutes": settings.ScanFrequencyMinutes.Int32,
|
|
"auto_scan_enabled": settings.AutoScanEnabled.Bool,
|
|
})
|
|
}
|
|
|
|
// 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{
|
|
"user_id": userID,
|
|
"user_role": userRole,
|
|
"user_email": userEmail,
|
|
"user_username": userUsername,
|
|
"exp": time.Now().Add(24 * time.Hour).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")
|
|
}
|