From 7b266a90010784ca32b889954a4a5b6201b46a14 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 14 Jul 2026 23:36:30 +0200 Subject: [PATCH] fix(client): refuse a bulk quota reduction that would fall to or below zero BulkAdjust clamped a client's new traffic limit with max(total+addBytes, 0). Because 0 is the unlimited sentinel, reducing a client's quota by more than it had left silently granted that client unlimited traffic. The sibling expiry branch already refuses an over-reduction; mirror it for quota so the adjustment is skipped with a clear reason instead of crossing the sentinel. --- internal/web/service/client_bulk.go | 12 ++++++--- .../web/service/client_bulk_reenable_test.go | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 81daa1346..6a18dbf19 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -359,9 +359,15 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, skippedReasons[email] = "unlimited traffic" } } else { - next := max(rec.TotalGB+addBytes, 0) - entry.applyTotal = true - entry.newTotal = next + next := rec.TotalGB + addBytes + if next <= 0 { + if _, exists := skippedReasons[email]; !exists { + skippedReasons[email] = "reduction exceeds remaining quota" + } + } else { + entry.applyTotal = true + entry.newTotal = next + } } } if entry.applyExpiry || entry.applyTotal || adjustFlow { diff --git a/internal/web/service/client_bulk_reenable_test.go b/internal/web/service/client_bulk_reenable_test.go index a03b28d4d..693bc0f4f 100644 --- a/internal/web/service/client_bulk_reenable_test.go +++ b/internal/web/service/client_bulk_reenable_test.go @@ -164,6 +164,31 @@ func TestBulkAdjust_ReenablesOverQuota_WhenAddBytesClearsQuota(t *testing.T) { } } +func TestBulkAdjust_QuotaReductionBelowZeroSkipsInsteadOfUnlimited(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + inboundSvc := &InboundService{} + + email := "qr@x" + c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true, TotalGB: 10} + ib := mkInbound(t, 52020, model.VLESS, clientsSettings(t, []model.Client{c})) + if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil { + t.Fatalf("seed linkage: %v", err) + } + mkTraffic(t, ib.Id, email, 0, 0, 10, 0, true) + + res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "") + if err != nil { + t.Fatalf("BulkAdjust: %v", err) + } + if res.Adjusted != 0 { + t.Fatalf("over-reduction should not adjust, got Adjusted=%d skipped=%v", res.Adjusted, res.Skipped) + } + if got := trafficOf(t, email).Total; got != 10 { + t.Fatalf("quota reduced past zero was written as %d (0 means unlimited); want it left at 10", got) + } +} + func TestBulkAdjust_OverQuota_DaysOnly_StaysDisabled(t *testing.T) { setupBulkDB(t) svc := &ClientService{}