package handlers import ( "bookmann/internal/database" "fmt" "net/http" "time" jwtgo "github.com/golang-jwt/jwt" "github.com/google/uuid" "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"` } 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"` } // 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") 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, `
Invalid request
`) } 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, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } email = req.Email username = req.Username password = req.Password } req := RegisterRequest{Email: email, Username: username, Password: password} if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) } 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, `
Email already exists
`) } 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, `
Username already exists
`) } return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"}) } // 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, `
Failed to hash password
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) } // Create user user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{ Email: req.Email, Username: req.Username, PasswordHash: string(hashedPassword), }) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
`+err.Error()+`
`) } return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // Generate JWT token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String()) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to generate token
`) } 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(`
Registration successful! Redirecting...
`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username)) return c.HTML(http.StatusCreated, html) } return c.JSON(http.StatusCreated, AuthResponse{ Token: token, User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, Username: user.Username, }, }) } // 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, `
Invalid request
`) } 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, `
`+err.Error()+`
`) } 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, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } if err := c.Validate(&req); err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusBadRequest, `
`+err.Error()+`
`) } return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Get user by email or username user, err := h.db.GetUserByEmailOrUsername(c.Request().Context(), req.Login) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusUnauthorized, `
Invalid credentials
`) } 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, `
Invalid credentials
`) } return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } // Generate JWT token, err := h.generateJWT(uuid.UUID(user.ID.Bytes).String()) if err != nil { if c.Request().Header.Get("HX-Request") == "true" { return c.HTML(http.StatusInternalServerError, `
Failed to generate token
`) } 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(`
Login successful! Redirecting...
`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username)) return c.HTML(http.StatusOK, html) } return c.JSON(http.StatusOK, AuthResponse{ Token: token, User: UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, Username: user.Username, }, }) } // 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 { return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"}) } return c.JSON(http.StatusOK, UserProfile{ ID: uuid.UUID(user.ID.Bytes).String(), Email: user.Email, Username: user.Username, }) } // ListUsers handles GET /api/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"` Theme string `json:"theme"` 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 } 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, Theme: theme, CreatedAt: createdAt, UpdatedAt: updatedAt, }) } return c.JSON(http.StatusOK, userList) } type AddEbookFolderRequest struct { FolderPath string `json:"folder_path" validate:"required"` } type EbookFolderResponse struct { ID string `json:"id"` UserID string `json:"user_id"` FolderPath string `json:"folder_path"` CreatedAt string `json:"created_at"` } // AddEbookFolder handles POST /api/auth/ebook-folders func (h *AuthHandler) AddEbookFolder(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 AddEbookFolderRequest 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()}) } folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, FolderPath: req.FolderPath, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusCreated, EbookFolderResponse{ ID: uuid.UUID(folder.ID.Bytes).String(), UserID: uuid.UUID(folder.UserID.Bytes).String(), FolderPath: folder.FolderPath, CreatedAt: folder.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), }) } // GetEbookFolders handles GET /api/auth/ebook-folders func (h *AuthHandler) GetEbookFolders(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"}) } folders, err := h.db.GetUserEbookFolders(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } var response []EbookFolderResponse for _, folder := range folders { response = append(response, EbookFolderResponse{ ID: uuid.UUID(folder.ID.Bytes).String(), UserID: uuid.UUID(folder.UserID.Bytes).String(), FolderPath: folder.FolderPath, CreatedAt: folder.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), }) } return c.JSON(http.StatusOK, response) } // DeleteEbookFolder handles DELETE /api/auth/ebook-folders/:folderPath func (h *AuthHandler) DeleteEbookFolder(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"}) } folderPath := c.Param("folderPath") if folderPath == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder path is required"}) } err = h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, FolderPath: folderPath, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder removed"}) } 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/user/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/user/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/user/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 { 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/user/account func (h *AuthHandler) DeleteAccount(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"}) } // 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 { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]string{"message": "account deleted successfully"}) } func (h *AuthHandler) generateJWT(userID string) (string, error) { claims := jwtgo.MapClaims{ "user_id": userID, "exp": time.Now().Add(24 * time.Hour).Unix(), "iat": time.Now().Unix(), } token := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, claims) return token.SignedString(h.jwtKey) }