package handlers import ( "log" "net/http" "regexp" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/db" "github.com/imc-vibe/backend/internal/mail" ) type UserHandler struct { db *db.DB } func NewUserHandler(database *db.DB) *UserHandler { return &UserHandler{db: database} } type CreateUserRequest struct { Email string `json:"email" binding:"required"` Password string `json:"password" binding:"required"` Quota int64 `json:"quota"` } type UpdateUserRequest struct { Password string `json:"password,omitempty"` Quota int64 `json:"quota,omitempty"` } type UserWithQuota struct { ID uint `json:"id"` Email string `json:"email"` Quota int64 `json:"quota"` UsedQuota *int64 `json:"usedQuota"` } func (h *UserHandler) List(c *gin.Context) { domainName := c.Param("name") if domainName == "" { Error(c, http.StatusBadRequest, "domain name required") return } authCtx := GetAuthContext(c) if authCtx == nil { Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { Error(c, http.StatusNotFound, "domain not found") return } users, err := h.db.GetUsersByDomain(domain.ID) if err != nil { Error(c, http.StatusInternalServerError, "database error") return } result := make([]UserWithQuota, len(users)) for i, user := range users { result[i] = UserWithQuota{ ID: user.ID, Email: user.Email, Quota: user.Quota, } quota, err := mail.GetQuota(user.Email) if err == nil && quota != nil { result[i].Quota = quota.Limit result[i].UsedQuota = "a.Used } } Success(c, result) } func (h *UserHandler) Get(c *gin.Context) { domainName := c.Param("name") idStr := c.Param("id") if domainName == "" || idStr == "" { Error(c, http.StatusBadRequest, "domain name and user id required") return } authCtx := GetAuthContext(c) if authCtx == nil { Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return } id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { Error(c, http.StatusBadRequest, "invalid user id") return } user, err := h.db.GetUserByID(uint(id)) if err != nil { Error(c, http.StatusNotFound, "user not found") return } Success(c, user) } func (h *UserHandler) Create(c *gin.Context) { domainName := c.Param("name") if domainName == "" { Error(c, http.StatusBadRequest, "domain name required") return } authCtx := GetAuthContext(c) if authCtx == nil { Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { Error(c, http.StatusNotFound, "domain not found") return } var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { Error(c, http.StatusBadRequest, "invalid request body") return } // Validate email local part (before @) if err := validateEmailLocalPart(req.Email); err != nil { Error(c, http.StatusBadRequest, err.Error()) return } existing, _ := h.db.GetUserByEmail(req.Email) if existing != nil { Error(c, http.StatusConflict, "user already exists") return } passwordHash := "{BLF-CRYPT}" + req.Password user, err := h.db.CreateUserInDomain(req.Email, passwordHash, req.Quota, domain.ID) if err != nil { log.Printf("CreateUserInDomain error: email=%s, domain=%s, err=%v", req.Email, domainName, err) Error(c, http.StatusInternalServerError, "failed to create user") return } Created(c, user) } func (h *UserHandler) Update(c *gin.Context) { domainName := c.Param("name") idStr := c.Param("id") if domainName == "" || idStr == "" { Error(c, http.StatusBadRequest, "domain name and user id required") return } authCtx := GetAuthContext(c) if authCtx == nil { Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return } id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { Error(c, http.StatusBadRequest, "invalid user id") return } user, err := h.db.GetUserByID(uint(id)) if err != nil { Error(c, http.StatusNotFound, "user not found") return } var req UpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { Error(c, http.StatusBadRequest, "invalid request") return } if req.Password != "" { passwordHash := "{BLF-CRYPT}" + req.Password if err := h.db.UpdateUserPassword(user.ID, passwordHash); err != nil { Error(c, http.StatusInternalServerError, "failed to update password") return } } if req.Quota >= 0 { if err := h.db.UpdateUserQuota(user.ID, req.Quota); err != nil { Error(c, http.StatusInternalServerError, "failed to update quota") return } } Success(c, map[string]string{"message": "user updated"}) } func (h *UserHandler) Delete(c *gin.Context) { domainName := c.Param("name") idStr := c.Param("id") if domainName == "" || idStr == "" { Error(c, http.StatusBadRequest, "domain name and user id required") return } authCtx := GetAuthContext(c) if authCtx == nil { Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { Error(c, http.StatusForbidden, "access denied") return } id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { Error(c, http.StatusBadRequest, "invalid user id") return } if err := h.db.DeleteUser(uint(id)); err != nil { Error(c, http.StatusInternalServerError, "failed to delete user") return } NoContent(c) } func (h *UserHandler) ListAll(c *gin.Context) { authCtx := GetAuthContext(c) if authCtx == nil || !authCtx.IsAdmin() { Error(c, http.StatusForbidden, "admin access required") return } users, err := h.db.GetAllMailUsers() if err != nil { Error(c, http.StatusInternalServerError, "database error") return } Success(c, users) } var emailLocalPartRegex = regexp.MustCompile("^[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~-]+$") func validateEmailLocalPart(email string) error { parts := strings.Split(email, "@") if len(parts) != 2 { return &ValidationError{Message: "invalid email format"} } localPart := parts[0] if len(localPart) < 1 || len(localPart) > 64 { return &ValidationError{Message: "username must be between 1 and 64 characters"} } // RFC 5321: local-part cannot start or end with a dot if strings.HasPrefix(localPart, ".") || strings.HasSuffix(localPart, ".") { return &ValidationError{Message: "username cannot start or end with a dot"} } // RFC 5321: local-part cannot contain consecutive dots if strings.Contains(localPart, "..") { return &ValidationError{Message: "username cannot contain consecutive dots"} } // Check valid characters (RFC 5321: letters, digits, and special chars !#$%&'*+/=?^_`{|}~-) if !emailLocalPartRegex.MatchString(localPart) { return &ValidationError{Message: "username contains invalid characters"} } return nil }