Skip to content

Commit 5f322da

Browse files
committed
Merge branch 'release/v12.0.0' of https://github.com/utmstack/UTMStack into release/v12.0.0
2 parents 7b80e81 + 4b95042 commit 5f322da

76 files changed

Lines changed: 2178 additions & 585 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎agent-manager/agent/agent_imp.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,16 @@ func (s *AgentService) ListAgents(ctx context.Context, req *ListRequest) (*ListA
261261

262262
// Scoped by the caller: the panel asks for one tenant, and only the
263263
// platform asks for all of them.
264-
where := ""
265264
if req.GetTenantId() != "" {
266-
where = fmt.Sprintf("tenant_id = '%s'", sanitizeTenant(req.GetTenantId()))
265+
filter = append(filter, utils.Filter{
266+
Field: "tenant_id",
267+
Op: utils.Is,
268+
Value:sanitizeTenant(req.GetTenantId()),
269+
})
267270
}
268271

269272
agents := []models.Agent{}
270-
total, err := s.DBConnection.GetByPagination(&agents, page, filter, where, false)
273+
total, err := s.DBConnection.GetByPagination(&agents, page, filter, "", false)
271274
if err != nil {
272275
catcher.Error("failed to fetch agents", err, map[string]any{"process": "agent-manager"})
273276
return nil, status.Errorf(codes.Internal, "failed to fetch agents: %v", err)

‎agent-manager/agent/collector_imp.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,16 @@ func (s *CollectorService) ListCollector(ctx context.Context, req *ListRequest)
258258

259259
// Scoped by the caller: the panel asks for one tenant, and only the
260260
// platform asks for all of them.
261-
where := ""
262261
if req.GetTenantId() != "" {
263-
where = fmt.Sprintf("tenant_id = '%s'", sanitizeTenant(req.GetTenantId()))
262+
filter = append(filter, utils.Filter{
263+
Field: "tenant_id",
264+
Op: utils.Is,
265+
Value:sanitizeTenant(req.GetTenantId()),
266+
})
264267
}
265268

266269
collectors := []models.Collector{}
267-
total, err := s.DBConnection.GetByPagination(&collectors, page, filter, where, false)
270+
total, err := s.DBConnection.GetByPagination(&collectors, page, filter, "", false)
268271
if err != nil {
269272
catcher.Error("failed to fetch collectors", err, map[string]any{"process": "agent-manager"})
270273
return nil, status.Errorf(codes.Internal, "failed to fetch collectors: %v", err)

‎backend/main.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ func main() {
3838

3939
modules := initModules(db, cfg)
4040

41-
adminEmail := env.String("UTMSTACK_ADMIN_EMAIL", "admin@localhost", false)
41+
adminEmail := env.String("UTMSTACK_ADMIN_EMAIL", "admin", false)
4242
created, err := modules.tenant.GetBootstrapUsecase().EnsureDefaultTenant(
43-
appCtx, adminEmail, env.String("UTMSTACK_ADMIN_PASSWORD", "", false))
43+
appCtx, adminEmail,
44+
env.String("UTMSTACK_ADMIN_PASSWORD", "", false),
45+
env.String("UTMSTACK_DEFAULT_DOMAIN", "", false))
4446
if err != nil {
4547
_ = catcher.Error("failed to create the default tenant", err, nil)
4648
panic(err)

‎backend/modules/alerts/domain/alert.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ type UtmAlert struct {
137137
StatusObservation string `json:"statusObservation,omitempty"`
138138
Impact *Impact `json:"impact,omitempty"`
139139
ImpactScore int `json:"impactScore,omitempty"`
140+
Echoes int64 `json:"echoes,omitempty"`
140141
Adversary *Side `json:"adversary,omitempty"`
141142
Target *Side `json:"target,omitempty"`
142143
LastEvent json.RawMessage `json:"lastEvent,omitempty"`

‎backend/modules/appconfig/connectors/repository.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@ type Repository interface {
1111
GetByKey(ctx context.Context, key string) (*domain.Config, error)
1212
GetOwn(ctx context.Context, key string) (*domain.Config, error)
1313
Save(ctx context.Context, c *domain.Config) error
14+
// CountValueContains returns how many rows for `key` (across all tenants)
15+
// have `needle` as a substring of their JSON value. Used to check whether
16+
// a branding asset URL is still referenced before deleting its file.
17+
CountValueContains(ctx context.Context, key, needle string) (int, error)
1418
}

‎backend/modules/appconfig/connectors/usecase.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ type BrandingUsecase interface {
1919
Get(ctx context.Context) (*dto.BrandingResponse, error)
2020
Update(ctx context.Context, actor string, req dto.BrandingRequest) (*dto.BrandingResponse, error)
2121
Seed(ctx context.Context, req dto.BrandingRequest) (*dto.BrandingResponse, error)
22-
SetAsset(ctx context.Context, actor, slot, url string) (*dto.BrandingResponse, error)
22+
// SetAsset returns the updated branding plus the URL previously stored in
23+
// `slot` (empty if none). Callers use the previous URL to garbage-collect
24+
// the now-unreferenced file.
25+
SetAsset(ctx context.Context, actor, slot, url string) (resp *dto.BrandingResponse, previousURL string, err error)
26+
IsBrandingAssetReferenced(ctx context.Context, url string) (bool, error)
2327
GetPublic(ctx context.Context) (*dto.BrandingPublic, error)
2428
BrandNameProvider
2529
}

‎backend/modules/appconfig/handler/branding_assets.go‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handler
22

33
import (
44
"bytes"
5+
"context"
56
"crypto/rand"
67
"encoding/hex"
78
"errors"
@@ -99,24 +100,25 @@ func (h *BrandingHandler) storeBrandingFile(slot string, fh *multipart.FileHeade
99100
return "", err
100101
}
101102

102-
// Best-effort: drop older files for this slot so they don't accumulate.
103-
deleteBrandingFilesExcept(dir, slot+"-", filename)
104103
return brandingURLPrefix + "/" + filename, nil
105104
}
106105

107-
func deleteBrandingFilesExcept(dir, prefix, keep string) {
108-
entries, err := os.ReadDir(dir)
109-
if err != nil {
106+
// removeIfUnreferenced deletes the file backing `url` when no tenant's branding
107+
// row still references it. `url` must be a stored branding URL (returned by
108+
// storeBrandingFile) — external URLs are ignored.
109+
func (h *BrandingHandler) removeIfUnreferenced(ctx context.Context, url string) {
110+
if !strings.HasPrefix(url, brandingURLPrefix+"/") {
110111
return
111112
}
112-
for _, e := range entries {
113-
if e.IsDir() {
114-
continue
115-
}
116-
if name := e.Name(); strings.HasPrefix(name, prefix) && name != keep {
117-
_ = os.Remove(filepath.Join(dir, name))
118-
}
113+
referenced, err := h.usecase.IsBrandingAssetReferenced(ctx, url)
114+
if err != nil || referenced {
115+
return
116+
}
117+
name := strings.TrimPrefix(url, brandingURLPrefix+"/")
118+
if name == "" || strings.ContainsAny(name, "/\\") {
119+
return
119120
}
121+
_ = os.Remove(filepath.Join(h.uploadDir, brandingSubdir, name))
120122
}
121123

122124
// UploadAsset godoc
@@ -155,7 +157,7 @@ func (h *BrandingHandler) UploadAsset(c *gin.Context) {
155157
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": err.Error()})
156158
return
157159
}
158-
resp, err := h.usecase.SetAsset(c.Request.Context(), c.GetString("user_email"), slot, url)
160+
resp, previous, err := h.usecase.SetAsset(c.Request.Context(), c.GetString("user_email"), slot, url)
159161
audit.Record(c, audit_connectors.Event{Action: "branding.asset.uploaded", ResourceType: "branding", ResourceID: slot},
160162
audit_domain.CONFIG_CHANGED, audit_domain.CONFIG_CHANGED, err)
161163
if errors.Is(err, usecase.ErrUnknownAssetSlot) {
@@ -167,6 +169,9 @@ func (h *BrandingHandler) UploadAsset(c *gin.Context) {
167169
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save asset"})
168170
return
169171
}
172+
if previous != "" && previous != url {
173+
h.removeIfUnreferenced(c.Request.Context(), previous)
174+
}
170175
c.JSON(http.StatusOK, resp)
171176
}
172177

‎backend/modules/appconfig/handler/bulk_branding.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,21 @@ func (h *BulkBrandingHandler) UploadAsset(c *gin.Context) {
141141
}
142142
actorEmail := c.GetString("user_email")
143143
var result common_models.BulkResult
144+
replaced := make(map[string]struct{})
144145
for _, tid := range tenantIDs {
145146
// ponytail: skip default (platform-plane) tenant — bulk calls must not silently overwrite operator branding
146147
if tid == authz.DefaultTenantID {
147148
continue
148149
}
149150
ctx := authz.WithTenantID(c.Request.Context(), tid)
150-
_, err := h.brand.SetAsset(ctx, actorEmail, slot, url)
151+
_, previous, err := h.brand.SetAsset(ctx, actorEmail, slot, url)
151152
result.Append(tid, err)
153+
if err == nil && previous != "" && previous != url {
154+
replaced[previous] = struct{}{}
155+
}
156+
}
157+
for previous := range replaced {
158+
bh.removeIfUnreferenced(c.Request.Context(), previous)
152159
}
153160
c.JSON(http.StatusOK, result)
154161
}

‎backend/modules/appconfig/repository/config.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ func (r *pgRepo) GetOwn(ctx context.Context, key string) (*domain.Config, error)
9999
return &c, nil
100100
}
101101

102+
// CountValueContains counts rows for `key` across every tenant whose JSON value
103+
// contains `needle`. Branding URLs carry a 16-hex-char nonce, so a substring
104+
// match is unambiguous — no other row's value can collide.
105+
func (r *pgRepo) CountValueContains(ctx context.Context, key, needle string) (int, error) {
106+
var n int64
107+
err := r.db.WithContext(tenancy.WithAllTenantsRead(ctx)).
108+
Model(&domain.Config{}).
109+
Where("key = ? AND value LIKE ?", key, "%"+needle+"%").
110+
Count(&n).Error
111+
if err != nil {
112+
return 0, err
113+
}
114+
return int(n), nil
115+
}
116+
102117
func (r *pgRepo) Save(ctx context.Context, c *domain.Config) error {
103118
tenant := actingTenant(ctx)
104119

‎backend/modules/appconfig/usecase/branding.go‎

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ func (s *brandingService) isWhiteLabelEntitled(_ context.Context) bool {
5151
}
5252

5353
// read loads the stored branding (JSON in the `branding` config row), falling
54-
// back to defaults when unset or unparseable.
54+
// back to defaults when unset or unparseable. Uses GetOwn so a tenant without
55+
// its own row gets defaults instead of inheriting the master tenant's brand.
5556
func (s *brandingService) read(ctx context.Context) (dto.BrandingResponse, error) {
5657
resp := dto.BrandingResponse{ProductName: defaultProductName}
57-
row, err := s.repo.GetByKey(ctx, brandingConfigKey)
58+
row, err := s.repo.GetOwn(ctx, brandingConfigKey)
5859
if err != nil {
5960
return resp, err
6061
}
@@ -133,29 +134,44 @@ func (s *brandingService) Seed(ctx context.Context, req dto.BrandingRequest) (*d
133134
return &resp, nil
134135
}
135136

136-
func (s *brandingService) SetAsset(ctx context.Context, actor, slot, url string) (*dto.BrandingResponse, error) {
137+
func (s *brandingService) SetAsset(ctx context.Context, actor, slot, url string) (*dto.BrandingResponse, string, error) {
137138
cur, err := s.read(ctx)
138139
if err != nil {
139-
return nil, err
140+
return nil, "", err
140141
}
142+
var previous string
141143
switch slot {
142144
case AssetLogo:
143-
cur.LogoURL = url
145+
previous, cur.LogoURL = cur.LogoURL, url
144146
case AssetLogoDark:
145-
cur.LogoDarkURL = url
147+
previous, cur.LogoDarkURL = cur.LogoDarkURL, url
146148
case AssetFavicon:
147-
cur.FaviconURL = url
149+
previous, cur.FaviconURL = cur.FaviconURL, url
148150
case AssetReportLogo:
149-
cur.ReportLogoURL = url
151+
previous, cur.ReportLogoURL = cur.ReportLogoURL, url
150152
case AssetReportCover:
151-
cur.ReportCoverURL = url
153+
previous, cur.ReportCoverURL = cur.ReportCoverURL, url
152154
default:
153-
return nil, ErrUnknownAssetSlot
155+
return nil, "", ErrUnknownAssetSlot
154156
}
155157
if err := s.save(ctx, actor, &cur); err != nil {
156-
return nil, err
158+
return nil, "", err
159+
}
160+
return &cur, previous, nil
161+
}
162+
163+
// IsBrandingAssetReferenced reports whether any tenant's branding row still
164+
// mentions `url`. Callers use this after replacing an asset to decide whether
165+
// the file on disk can be removed.
166+
func (s *brandingService) IsBrandingAssetReferenced(ctx context.Context, url string) (bool, error) {
167+
if strings.TrimSpace(url) == "" {
168+
return false, nil
169+
}
170+
n, err := s.repo.CountValueContains(ctx, brandingConfigKey, url)
171+
if err != nil {
172+
return false, err
157173
}
158-
return &cur, nil
174+
return n > 0, nil
159175
}
160176

161177
// GetPublic returns the effective branding for the (unauthenticated) login page.

0 commit comments

Comments
 (0)