Files
2026-05-20 15:37:25 +08:00

154 lines
3.9 KiB
Go

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)
}