package middleware import ( "context" "errors" "fmt" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" ) const BrandIDHeader = "X-Brand-ID" func BrandScope(pool *pgxpool.Pool) gin.HandlerFunc { return func(c *gin.Context) { actor, ok := auth.ActorFromCtx(c.Request.Context()) if !ok || actor.TenantID == 0 || actor.UserID == 0 { response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated")) c.Abort() return } brandID, hasHeader, err := requestedBrandID(c.GetHeader(BrandIDHeader)) if err != nil { response.Error(c, response.ErrBadRequest(40033, "invalid_brand_id", "brand id must be a positive number")) c.Abort() return } if !hasHeader { c.Next() return } if pool == nil { response.Error(c, response.ErrInternal(50012, "brand_scope_unavailable", "brand scope validation is unavailable")) c.Abort() return } if err := validateBrandScope(c.Request.Context(), pool, actor.TenantID, brandID); err != nil { if errors.Is(err, pgx.ErrNoRows) { response.Error(c, response.ErrForbidden(40313, "brand_forbidden", "brand is not available for current tenant")) } else { response.Error(c, response.ErrInternal(50012, "brand_scope_lookup_failed", "failed to validate brand scope")) } c.Abort() return } c.Request = c.Request.WithContext(auth.WithCurrentBrandID(c.Request.Context(), brandID)) c.Next() } } func RequireCurrentBrand() gin.HandlerFunc { return func(c *gin.Context) { if _, ok := auth.CurrentBrandIDFromCtx(c.Request.Context()); !ok { response.Error(c, response.ErrBadRequest(40033, "brand_context_required", "current brand is required")) c.Abort() return } c.Next() } } func ArticleBrandScope(pool *pgxpool.Pool) gin.HandlerFunc { return func(c *gin.Context) { rawArticleID := strings.TrimSpace(c.Param("id")) if rawArticleID == "" { c.Next() return } articleID, err := strconv.ParseInt(rawArticleID, 10, 64) if err != nil || articleID <= 0 { c.Next() return } actor, ok := auth.ActorFromCtx(c.Request.Context()) if !ok || actor.TenantID == 0 { response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated")) c.Abort() return } brandID, ok := auth.CurrentBrandIDFromCtx(c.Request.Context()) if !ok { response.Error(c, response.ErrBadRequest(40033, "brand_context_required", "current brand is required")) c.Abort() return } if pool == nil { response.Error(c, response.ErrInternal(50012, "brand_scope_unavailable", "brand scope validation is unavailable")) c.Abort() return } var exists bool if err := pool.QueryRow(c.Request.Context(), ` SELECT EXISTS ( SELECT 1 FROM articles WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL ) `, articleID, actor.TenantID, brandID).Scan(&exists); err != nil { response.Error(c, response.ErrInternal(50012, "brand_scope_lookup_failed", "failed to validate article brand scope")) c.Abort() return } if !exists { response.Error(c, response.ErrNotFound(40411, "article_not_found", "article not found")) c.Abort() return } c.Next() } } func requestedBrandID(raw string) (int64, bool, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return 0, false, nil } brandID, err := strconv.ParseInt(trimmed, 10, 64) if err != nil { return 0, true, err } if brandID <= 0 { return 0, true, fmt.Errorf("brand id must be positive") } return brandID, true, nil } func validateBrandScope(ctx context.Context, pool *pgxpool.Pool, tenantID, brandID int64) error { var exists int return pool.QueryRow(ctx, ` SELECT 1 FROM brands WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL LIMIT 1 `, tenantID, brandID).Scan(&exists) }