Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ require (
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/threatwinds/go-sdk v1.1.27-0.20260811073440-251cb9d842cd
github.com/threatwinds/go-sdk v1.1.28
github.com/tidwall/gjson v1.19.0
github.com/utmstack/license-manager-sdk v0.1.0
golang.org/x/crypto v0.55.0
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/threatwinds/go-sdk v1.1.27-0.20260811073440-251cb9d842cd h1:u5x6DfEkBErQurxPjtFYnd2xfW1ruD6kPxnYzGaHQCc=
github.com/threatwinds/go-sdk v1.1.27-0.20260811073440-251cb9d842cd/go.mod h1:Mr5r+NTTe8k28gVS7EgEorn76VPtf0vPSaUCD2bWRSU=
github.com/threatwinds/go-sdk v1.1.28 h1:dnYIAVC5jQgr7jm1VGMS7dQcl/08GVODDJp3dhV6D9U=
github.com/threatwinds/go-sdk v1.1.28/go.mod h1:aN6Oe3zJop9ngS83oZcKFXDLKWzrny2XhkYm7uoyDbQ=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
Expand Down
12 changes: 12 additions & 0 deletions backend/modules/appconfig/dto/bulk_branding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package dto

import "github.com/utmstack/utmstack/backend/pkg/common_models"

type BulkBrandingUpdateRequest struct {
Selector common_models.BulkTenantSelector `json:"selector"`
Branding BrandingRequest `json:"branding"`
}

type BulkBrandingAssetRequest struct {
Selector common_models.BulkTenantSelector `json:"selector"`
}
20 changes: 20 additions & 0 deletions backend/modules/appconfig/dto/bulk_smtp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dto

import "github.com/utmstack/utmstack/backend/pkg/common_models"

// BulkSMTPField is one key→value pair to upsert.
type BulkSMTPField struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
}

// BulkSMTPUpdateRequest sets SMTP fields across N tenants.
type BulkSMTPUpdateRequest struct {
Selector common_models.BulkTenantSelector `json:"selector"`
Fields []BulkSMTPField `json:"fields" binding:"required,min=1"`
}

// BulkSMTPTestRequest sends a test mail from N tenants.
type BulkSMTPTestRequest struct {
Selector common_models.BulkTenantSelector `json:"selector"`
}
154 changes: 154 additions & 0 deletions backend/modules/appconfig/handler/bulk_branding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package handler

import (
"context"
"encoding/json"
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/utmstack/utmstack/backend/modules/appconfig/connectors"
"github.com/utmstack/utmstack/backend/modules/appconfig/dto"
"github.com/utmstack/utmstack/backend/pkg/authz"
"github.com/utmstack/utmstack/backend/pkg/common_models"
)

// BulkBrandingHandler applies branding changes across multiple tenants in one call.
type BulkBrandingHandler struct {
brand connectors.BrandingUsecase
uploadDir string
tenantLister func(context.Context) ([]string, error)
}

func NewBulkBrandingHandler(brand connectors.BrandingUsecase, uploadDir string, tenantLister func(context.Context) ([]string, error)) *BulkBrandingHandler {
return &BulkBrandingHandler{brand: brand, uploadDir: uploadDir, tenantLister: tenantLister}
}

// resolveTenants returns the target tenant IDs, enumerating active ones when AllTenants is set.
// ponytail: DefaultTenantID filtered out of AllTenants — bulk ops must not
// overwrite the platform-plane config; callers can still target it explicitly
// via TenantIDs.
func resolveTenants(ctx context.Context, sel common_models.BulkTenantSelector, lister func(context.Context) ([]string, error)) ([]string, error) {
if !sel.AllTenants {
return sel.TenantIDs, nil
}
all, err := lister(ctx)
if err != nil {
return nil, err
}
out := make([]string, 0, len(all))
for _, id := range all {
if id != authz.DefaultTenantID {
out = append(out, id)
}
}
return out, nil
}

// Update godoc
//
// @Summary Bulk update branding across tenants
// @Description Applies the same branding configuration to multiple tenants. Partial failures are recorded; succeeded tenants are listed separately.
// @Tags Branding
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param input body dto.BulkBrandingUpdateRequest true "Selector + branding overrides"
// @Success 200 {object} common_models.BulkResult
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /platform/branding/bulk/update [post]
func (h *BulkBrandingHandler) Update(c *gin.Context) {
var req dto.BulkBrandingUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
actorEmail := c.GetString("user_email")
var result common_models.BulkResult
for _, tid := range tenantIDs {
// ponytail: skip default (platform-plane) tenant — bulk calls must not silently overwrite operator branding
if tid == authz.DefaultTenantID {
continue
}
ctx := authz.WithTenantID(c.Request.Context(), tid)
_, err := h.brand.Update(ctx, actorEmail, req.Branding)
result.Append(tid, err)
}
c.JSON(http.StatusOK, result)
}

// UploadAsset godoc
//
// @Summary Bulk upload a branding asset across tenants
// @Description Saves the file once then points the given slot URL at it for every selected tenant. Partial failures are recorded.
// @Tags Branding
// @Security BearerAuth
// @Accept multipart/form-data
// @Produce json
// @Param slot path string true "Asset slot: logo|logoDark|favicon|reportLogo|reportCover"
// @Param file formData file true "Image file (png/jpg/webp/gif/svg/ico, ≤5MB)"
// @Param selector formData string true "JSON-encoded BulkTenantSelector"
// @Success 200 {object} common_models.BulkResult
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /platform/branding/bulk/upload-asset/{slot} [post]
func (h *BulkBrandingHandler) UploadAsset(c *gin.Context) {
slot := c.Param("slot")
if !validBrandingSlots[slot] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown asset slot"})
return
}

// Parse selector from multipart form field "selector".
var sel common_models.BulkTenantSelector
if raw := strings.TrimSpace(c.PostForm("selector")); raw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing selector field"})
return
} else if err := json.Unmarshal([]byte(raw), &sel); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid selector JSON: " + err.Error()})
return
}

c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, brandingMaxBytes+512)
fh, err := c.FormFile("file")
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image is too large (max 5MB)"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}

// Reuse existing BrandingHandler to store the file once.
bh := &BrandingHandler{uploadDir: h.uploadDir}
url, err := bh.storeBrandingFile(slot, fh)
if err != nil {
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": err.Error()})
return
}

tenantIDs, err := resolveTenants(c.Request.Context(), sel, h.tenantLister)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
actorEmail := c.GetString("user_email")
var result common_models.BulkResult
for _, tid := range tenantIDs {
// ponytail: skip default (platform-plane) tenant — bulk calls must not silently overwrite operator branding
if tid == authz.DefaultTenantID {
continue
}
ctx := authz.WithTenantID(c.Request.Context(), tid)
_, err := h.brand.SetAsset(ctx, actorEmail, slot, url)
result.Append(tid, err)
}
c.JSON(http.StatusOK, result)
}
52 changes: 52 additions & 0 deletions backend/modules/appconfig/handler/bulk_branding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package handler

import (
"context"
"errors"
"testing"

"github.com/utmstack/utmstack/backend/pkg/common_models"
)

// stubLister returns a fixed list for testing.
func stubLister(ids ...string) func(context.Context) ([]string, error) {
return func(_ context.Context) ([]string, error) { return ids, nil }
}

func TestResolveTenants_explicit(t *testing.T) {
sel := common_models.BulkTenantSelector{TenantIDs: []string{"a", "b"}}
got, err := resolveTenants(context.Background(), sel, nil)
if err != nil || len(got) != 2 {
t.Fatalf("want 2 ids, got %v %v", got, err)
}
}

func TestResolveTenants_allFiltersDefault(t *testing.T) {
const defaultTID = "ce66672c-e36d-4761-a8c8-90058fee1a24"
sel := common_models.BulkTenantSelector{AllTenants: true}
got, err := resolveTenants(context.Background(), sel, stubLister(defaultTID, "tenant-2"))
if err != nil {
t.Fatal(err)
}
for _, id := range got {
if id == defaultTID {
t.Fatalf("DefaultTenantID must be filtered out, got %v", got)
}
}
if len(got) != 1 || got[0] != "tenant-2" {
t.Fatalf("want [tenant-2], got %v", got)
}
}

func TestBulkResultPartialFailure(t *testing.T) {
var r common_models.BulkResult
r.Append("t1", nil)
r.Append("t2", errors.New("boom"))
r.Append("t3", nil)
if len(r.Succeeded) != 2 || len(r.Failed) != 1 {
t.Fatalf("want 2 succeeded 1 failed, got %v / %v", r.Succeeded, r.Failed)
}
if r.Failed[0].TenantID != "t2" {
t.Fatalf("wrong failed tenant: %v", r.Failed[0])
}
}
111 changes: 111 additions & 0 deletions backend/modules/appconfig/handler/bulk_smtp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package handler

import (
"context"
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/utmstack/utmstack/backend/modules/appconfig/connectors"
"github.com/utmstack/utmstack/backend/modules/appconfig/domain"
"github.com/utmstack/utmstack/backend/modules/appconfig/dto"
"github.com/utmstack/utmstack/backend/pkg/authz"
"github.com/utmstack/utmstack/backend/pkg/common_models"
"github.com/utmstack/utmstack/backend/pkg/constants"
)

// BulkSMTPHandler handles platform-admin bulk SMTP operations.
type BulkSMTPHandler struct {
uc connectors.Usecase
store connectors.Store
tenantLister func(context.Context) ([]string, error)
}

func NewBulkSMTPHandler(uc connectors.Usecase, store connectors.Store, lister func(context.Context) ([]string, error)) *BulkSMTPHandler {
return &BulkSMTPHandler{uc: uc, store: store, tenantLister: lister}
}

// Update godoc
//
// @Summary Bulk update SMTP config across tenants
// @Tags Platform Config
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param input body dto.BulkSMTPUpdateRequest true "Fields + selector"
// @Success 200 {object} common_models.BulkResult
// @Router /platform/config/smtp/bulk/update [post]
func (h *BulkSMTPHandler) Update(c *gin.Context) {
var req dto.BulkSMTPUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
actor := c.GetString("user_email")
var result common_models.BulkResult
for _, tid := range tenantIDs {
ctx := authz.WithTenantID(c.Request.Context(), tid)
var tenErr error
for _, kv := range req.Fields {
if _, err := h.uc.Update(ctx, actor, kv.Key, dto.UpsertRequest{Value: kv.Value}); err != nil {
tenErr = err
break
}
}
result.Append(tid, tenErr)
}
c.JSON(http.StatusOK, result)
}

// Test godoc
//
// @Summary Bulk test SMTP config across tenants
// @Tags Platform Config
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param input body dto.BulkSMTPTestRequest true "Selector"
// @Success 200 {object} common_models.BulkResult
// @Router /platform/config/smtp/bulk/test [post]
func (h *BulkSMTPHandler) Test(c *gin.Context) {
var req dto.BulkSMTPTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantIDs, err := resolveTenants(c.Request.Context(), req.Selector, h.tenantLister)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var result common_models.BulkResult
for _, tid := range tenantIDs {
ctx := authz.WithTenantID(c.Request.Context(), tid)
cfg := h.loadMailConfig(ctx)
result.Append(tid, h.uc.CheckMail(ctx, []domain.MailConfig{cfg}))
}
c.JSON(http.StatusOK, result)
}

func (h *BulkSMTPHandler) loadMailConfig(ctx context.Context) domain.MailConfig {
get := func(key string) string {
v, _, _ := h.store.GetString(ctx, key)
return v
}
cfg := domain.MailConfig{
Host: get(constants.PROP_MAIL_HOST),
Username: get(constants.PROP_MAIL_USERNAME),
Password: get(constants.PROP_MAIL_PASSWORD),
From: get(constants.PROP_MAIL_FROM),
AuthType: get(constants.PROP_MAIL_SMTP_AUTH),
}
if p, err := strconv.Atoi(get(constants.PROP_MAIL_PORT)); err == nil {
cfg.Port = p
}
return cfg
}
Loading
Loading