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:
2026-01-23 09:08:04 -05:00
parent 152ed27200
commit 4318f8624b
31 changed files with 38 additions and 175 deletions
+420
View File
@@ -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)
}