Remove backtick, curly braces, pipe, and slash from allowed characters. Keep underscore as it's commonly used and widely supported.
318 lines
7.4 KiB
Go
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 = "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
|
|
}
|