imc-vibe/backend/internal/api/handlers/users.go
Christoph Haas 6b0e91798f Restrict email local part to safe characters only
Remove backtick, curly braces, pipe, and slash from allowed characters.
Keep underscore as it's commonly used and widely supported.
2026-03-26 00:49:35 +01:00

318 lines
7.4 KiB
Go

package handlers
import (
"log"
"net/http"
"regexp"
"strconv"
"strings"
"git.workaround.org/chaas/imc/backend/internal/db"
"git.workaround.org/chaas/imc/backend/internal/mail"
"github.com/gin-gonic/gin"
)
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 = &quota.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
}