refactor: restructure project from bookmann to shelf
- Rename project from 'bookmann' to 'shelf' - Move all backend/ contents to root level (flatten structure) - Update Go module name from 'bookmann' to 'shelf' - Update all import paths to use new 'shelf' module - Update Dockerfile to work without backend/ subdirectory - Update docker-compose.yml to use new structure and rename containers - Update .gitignore for new file paths - Update README.md with new project name and structure - Regenerate database code with new module imports
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"shelf/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, `<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
|
||||
}
|
||||
|
||||
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, `<div class="text-red-500">`+err.Error()+`</div>`)
|
||||
}
|
||||
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, `<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"})
|
||||
}
|
||||
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// 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, `<div class="text-red-500">`+err.Error()+`</div>`)
|
||||
}
|
||||
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, `<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));
|
||||
window.location.href = '/';
|
||||
</script>`, 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, `<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()})
|
||||
}
|
||||
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()})
|
||||
}
|
||||
|
||||
// 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, `<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
|
||||
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, `<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));
|
||||
window.location.href = '/';
|
||||
</script>`, 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"})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user