You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
316 lines
8.2 KiB
Go
316 lines
8.2 KiB
Go
package http
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
|
|
appu "knowfoolery/backend/services/user-service/internal/application/user"
|
|
domain "knowfoolery/backend/services/user-service/internal/domain/user"
|
|
sharederrors "knowfoolery/backend/shared/domain/errors"
|
|
"knowfoolery/backend/shared/infra/auth/zitadel"
|
|
"knowfoolery/backend/shared/infra/observability/logging"
|
|
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
|
|
"knowfoolery/backend/shared/infra/utils/httputil"
|
|
"knowfoolery/backend/shared/infra/utils/validation"
|
|
)
|
|
|
|
// Handler implements user service HTTP endpoint handlers.
|
|
type Handler struct {
|
|
service *appu.Service
|
|
validator *validation.Validator
|
|
logger *logging.Logger
|
|
metrics *sharedmetrics.Metrics
|
|
adminDefaultLimit int
|
|
adminMaxLimit int
|
|
}
|
|
|
|
// NewHandler creates a new HTTP handler set.
|
|
func NewHandler(
|
|
service *appu.Service,
|
|
validator *validation.Validator,
|
|
logger *logging.Logger,
|
|
metrics *sharedmetrics.Metrics,
|
|
adminDefaultLimit int,
|
|
adminMaxLimit int,
|
|
) *Handler {
|
|
if adminDefaultLimit <= 0 {
|
|
adminDefaultLimit = 50
|
|
}
|
|
if adminMaxLimit <= 0 {
|
|
adminMaxLimit = 200
|
|
}
|
|
return &Handler{
|
|
service: service,
|
|
validator: validator,
|
|
logger: logger,
|
|
metrics: metrics,
|
|
adminDefaultLimit: adminDefaultLimit,
|
|
adminMaxLimit: adminMaxLimit,
|
|
}
|
|
}
|
|
|
|
// RegisterUser handles POST /users/register.
|
|
func (h *Handler) RegisterUser(c fiber.Ctx) error {
|
|
var req RegisterUserRequest
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err))
|
|
}
|
|
if err := h.validator.Validate(req); err != nil {
|
|
return httputil.SendError(c, err)
|
|
}
|
|
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" || claims.Email == "" {
|
|
return httputil.SendError(c, domain.ErrUnauthorized)
|
|
}
|
|
|
|
u, err := h.service.Register(c.Context(), appu.RegisterInput{
|
|
ActorZitadelUserID: claims.UserID,
|
|
ActorEmail: claims.Email,
|
|
DisplayName: req.DisplayName,
|
|
ConsentVersion: req.ConsentVersion,
|
|
ConsentSource: req.ConsentSource,
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("POST", "/users/register", fiber.StatusOK)
|
|
return httputil.OK(c, toUserResponse(u))
|
|
}
|
|
|
|
// GetUser handles GET /users/:id.
|
|
func (h *Handler) GetUser(c fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
claims := authClaimsFromContext(c)
|
|
if !isSelfOrAdmin(claims, id) {
|
|
return httputil.SendError(c, domain.ErrForbidden)
|
|
}
|
|
|
|
u, err := h.service.GetProfile(c.Context(), id)
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("GET", "/users/{id}", fiber.StatusOK)
|
|
return httputil.OK(c, toUserResponse(u))
|
|
}
|
|
|
|
// UpdateUser handles PUT /users/:id.
|
|
func (h *Handler) UpdateUser(c fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
claims := authClaimsFromContext(c)
|
|
if !isSelfOrAdmin(claims, id) {
|
|
return httputil.SendError(c, domain.ErrForbidden)
|
|
}
|
|
|
|
var req UpdateUserRequest
|
|
if err := c.Bind().Body(&req); err != nil {
|
|
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err))
|
|
}
|
|
if err := h.validator.Validate(req); err != nil {
|
|
return httputil.SendError(c, err)
|
|
}
|
|
|
|
u, err := h.service.UpdateProfile(c.Context(), id, appu.UpdateProfileInput{
|
|
DisplayName: req.DisplayName,
|
|
ConsentVersion: req.ConsentVersion,
|
|
ConsentSource: req.ConsentSource,
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("PUT", "/users/{id}", fiber.StatusOK)
|
|
return httputil.OK(c, toUserResponse(u))
|
|
}
|
|
|
|
// DeleteUser handles DELETE /users/:id.
|
|
func (h *Handler) DeleteUser(c fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
claims := authClaimsFromContext(c)
|
|
if !isSelfOrAdmin(claims, id) {
|
|
return httputil.SendError(c, domain.ErrForbidden)
|
|
}
|
|
|
|
if err := h.service.DeleteUser(c.Context(), id, claims.UserID); err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("DELETE", "/users/{id}", fiber.StatusNoContent)
|
|
return httputil.NoContent(c)
|
|
}
|
|
|
|
// VerifyEmail handles POST /users/verify-email.
|
|
func (h *Handler) VerifyEmail(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if claims.UserID == "" {
|
|
return httputil.SendError(c, domain.ErrUnauthorized)
|
|
}
|
|
|
|
u, err := h.service.VerifyEmail(c.Context(), claims.UserID)
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("POST", "/users/verify-email", fiber.StatusOK)
|
|
return httputil.OK(c, toUserResponse(u))
|
|
}
|
|
|
|
// AdminListUsers handles GET /admin/users.
|
|
func (h *Handler) AdminListUsers(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if !claims.IsAdmin {
|
|
return httputil.SendError(c, domain.ErrForbidden)
|
|
}
|
|
|
|
page := atoiWithDefault(c.Query("page"), 1)
|
|
pageSize := atoiWithDefault(c.Query("page_size"), h.adminDefaultLimit)
|
|
if pageSize > h.adminMaxLimit {
|
|
pageSize = h.adminMaxLimit
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = h.adminDefaultLimit
|
|
}
|
|
|
|
createdAfter, err := parseTimeQuery(c.Query("created_after"))
|
|
if err != nil {
|
|
return httputil.SendError(
|
|
c,
|
|
sharederrors.Wrap(
|
|
sharederrors.CodeValidationFailed,
|
|
"created_after must be RFC3339",
|
|
err,
|
|
),
|
|
)
|
|
}
|
|
createdBefore, err := parseTimeQuery(c.Query("created_before"))
|
|
if err != nil {
|
|
return httputil.SendError(
|
|
c,
|
|
sharederrors.Wrap(
|
|
sharederrors.CodeValidationFailed,
|
|
"created_before must be RFC3339",
|
|
err,
|
|
),
|
|
)
|
|
}
|
|
|
|
users, total, normalized, err := h.service.AdminListUsers(c.Context(), appu.ListUsersInput{
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
Email: c.Query("email"),
|
|
DisplayName: c.Query("display_name"),
|
|
CreatedAfter: createdAfter,
|
|
CreatedBefore: createdBefore,
|
|
IncludeDeleted: strings.EqualFold(c.Query("include_deleted"), "true"),
|
|
})
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
|
|
resp := make([]UserResponse, 0, len(users))
|
|
for _, u := range users {
|
|
resp = append(resp, toUserResponse(u))
|
|
}
|
|
|
|
h.recordRequestMetric("GET", "/admin/users", fiber.StatusOK)
|
|
return httputil.Paginated(c, resp, normalized.Page, normalized.PageSize, total)
|
|
}
|
|
|
|
// AdminExportUser handles POST /admin/users/:id/export.
|
|
func (h *Handler) AdminExportUser(c fiber.Ctx) error {
|
|
claims := authClaimsFromContext(c)
|
|
if !claims.IsAdmin {
|
|
return httputil.SendError(c, domain.ErrForbidden)
|
|
}
|
|
|
|
bundle, err := h.service.AdminExportUser(c.Context(), c.Params("id"), claims.UserID)
|
|
if err != nil {
|
|
return h.sendMappedError(c, err)
|
|
}
|
|
h.recordRequestMetric("POST", "/admin/users/{id}/export", fiber.StatusOK)
|
|
return httputil.OK(c, bundle)
|
|
}
|
|
|
|
func (h *Handler) sendMappedError(c fiber.Ctx, err error) error {
|
|
var domainErr *sharederrors.DomainError
|
|
if errors.As(err, &domainErr) {
|
|
return httputil.SendError(c, domainErr)
|
|
}
|
|
if h.logger != nil {
|
|
h.logger.WithError(err).Error("user-service internal error")
|
|
}
|
|
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInternal, "internal error", err))
|
|
}
|
|
|
|
func (h *Handler) recordRequestMetric(method, endpoint string, status int) {
|
|
if h.metrics == nil {
|
|
return
|
|
}
|
|
h.metrics.HTTPRequestsTotal.WithLabelValues(method, endpoint, strconv.Itoa(status), "user-service").Inc()
|
|
}
|
|
|
|
type authClaims struct {
|
|
UserID string
|
|
Email string
|
|
Roles []string
|
|
EmailVerified bool
|
|
IsAdmin bool
|
|
}
|
|
|
|
func authClaimsFromContext(c fiber.Ctx) authClaims {
|
|
roles := zitadel.GetUserRoles(c)
|
|
claims := authClaims{
|
|
UserID: zitadel.GetUserID(c),
|
|
Email: zitadel.GetUserEmail(c),
|
|
Roles: roles,
|
|
EmailVerified: claimBool(c.Locals("email_verified")),
|
|
}
|
|
for _, role := range roles {
|
|
if role == "admin" {
|
|
claims.IsAdmin = true
|
|
break
|
|
}
|
|
}
|
|
return claims
|
|
}
|
|
|
|
func isSelfOrAdmin(claims authClaims, userID string) bool {
|
|
if claims.UserID == "" {
|
|
return false
|
|
}
|
|
if claims.IsAdmin {
|
|
return true
|
|
}
|
|
return claims.UserID == userID
|
|
}
|
|
|
|
func claimBool(v interface{}) bool {
|
|
if b, ok := v.(bool); ok {
|
|
return b
|
|
}
|
|
return false
|
|
}
|
|
|
|
func atoiWithDefault(v string, d int) int {
|
|
if strings.TrimSpace(v) == "" {
|
|
return d
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return d
|
|
}
|
|
return n
|
|
}
|
|
|
|
func parseTimeQuery(v string) (*time.Time, error) {
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil, nil
|
|
}
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|