- Fix type assertion panics in auth.go (9 handlers)
* GetProfile, UpdateProfile, UpdateTheme, UpdateUsername
* UpdateEmail, UpdatePassword, DeleteAccount
* UpdateScanSettings, GetScanSettings, Register admin check
* Replace c.Get("user_id").(string) with MustGetAuthenticatedUser()
- Fix type assertion panic in library.go
* GetUserVisibleLibraries now uses MustGetAuthenticatedUser()
- Add path traversal protection to AddLibraryFolder
* Detect and block ".." in paths
* Clean paths with filepath.Clean()
* Verify path is a directory before adding
- Remove debug logging from Login handler
* Removed all fmt.Printf statements
* No more plaintext password logging
- Create safe context helper functions
* internal/handlers/context.go added
* GetAuthenticatedUser() for safe retrieval
* MustGetAuthenticatedUser() for post-auth middleware
Security: Critical
Tests: All 62 integration tests pass
Breaking: None - backward compatible
889 lines
31 KiB
Go
889 lines
31 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookmann/internal/database"
|
|
"bookmann/internal/middleware"
|
|
"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
|
|
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 {
|
|
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()})
|
|
}
|
|
|
|
// Additional validation: Trim whitespace from username
|
|
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"})
|
|
}
|
|
|
|
// Normalize role to lowercase
|
|
if req.Role != "" {
|
|
req.Role = strings.ToLower(req.Role)
|
|
}
|
|
|
|
// 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)
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok || user.Role != "admin" {
|
|
// Not authenticated or not admin - 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 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
|
|
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"})
|
|
}
|
|
|
|
// Create refresh token
|
|
refreshToken, _, err := h.CreateRefreshToken(uuid.UUID(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"})
|
|
}
|
|
|
|
// 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('refreshToken', '%s');
|
|
localStorage.setItem('user', JSON.stringify(%s));
|
|
document.cookie = 'token=%s; path=/; max-age=3600';
|
|
window.location.href = '/bookshelf';
|
|
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username), accessToken)
|
|
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: 3600,
|
|
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 {
|
|
// Try form data first (HTMX), then JSON (Bruno)
|
|
login := c.FormValue("login")
|
|
password := c.FormValue("password")
|
|
|
|
if login == "" || password == "" {
|
|
// Fallback to JSON binding
|
|
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()})
|
|
}
|
|
|
|
// Check if user/IP is locked out
|
|
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})
|
|
}
|
|
|
|
// Get user by email or username (includes password hash for verification)
|
|
user, err := h.db.GetUserForLogin(c.Request().Context(), req.Login)
|
|
if err != nil {
|
|
// Record failed attempt
|
|
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.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 {
|
|
// Record failed attempt
|
|
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.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
|
|
}
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
|
}
|
|
|
|
// Clear failed attempts on successful login
|
|
h.loginAttemptTracker.ClearAttempts(login)
|
|
|
|
// Generate JWT with user details
|
|
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"})
|
|
}
|
|
|
|
// Create refresh token
|
|
refreshToken, _, err := h.CreateRefreshToken(uuid.UUID(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"})
|
|
}
|
|
|
|
// 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('refreshToken', '%s');
|
|
localStorage.setItem('user', JSON.stringify(%s));
|
|
document.cookie = 'token=%s; path=/; max-age=3600';
|
|
window.location.href = '/bookshelf';
|
|
</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), accessToken)
|
|
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: 3600,
|
|
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
|
|
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
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: user.ID,
|
|
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, map[string]interface{}{"data": 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 {
|
|
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
|
|
func (h *AuthHandler) UpdatePassword(c echo.Context) error {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
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(), user.ID)
|
|
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: user.ID,
|
|
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 {
|
|
currentUser := MustGetAuthenticatedUser(c)
|
|
|
|
// Get target user ID from query parameter (for admin override) or use current user
|
|
targetUserID := c.QueryParam("user_id")
|
|
var targetUserUUID pgtype.UUID
|
|
|
|
// If admin override is used, validate admin and use target
|
|
if targetUserID != "" {
|
|
// Admin override mode - check if current user is admin
|
|
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: [16]byte(parsedUUID), Valid: true}
|
|
} else {
|
|
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 := ""
|
|
for _, user := range users {
|
|
if user.Role == "admin" {
|
|
adminCount++
|
|
}
|
|
// Find target user details
|
|
if user.ID.Bytes == targetUserUUID.Bytes {
|
|
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(), targetUserUUID)
|
|
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 != "" && 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 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 {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
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: user.ID,
|
|
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 {
|
|
user := MustGetAuthenticatedUser(c)
|
|
|
|
settings, err := h.db.GetScanSettings(c.Request().Context(), user.ID)
|
|
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(1 * 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")
|
|
}
|