From f8ceede46f8f0177c4eae2b5989ca90e49743fdd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 08:39:50 +0100 Subject: [PATCH] fix(hwid): keep a subscription's device limit while its client is disabled effectiveHwidLimitForSubID took the limit over enabled clients only. A client switched off for expiry, quota or by hand therefore had no limit at all, while the subscription body is still served for disabled clients, so any number of devices registered and stayed past the renewal. The limit now follows the subId whatever the enable flag, and the gate, the slot status and the trim keep sharing that one query. --- internal/web/service/client_hwid.go | 6 ++-- internal/web/service/client_hwid_test.go | 42 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index 6d7b01b97..813dbd605 100644 --- a/internal/web/service/client_hwid.go +++ b/internal/web/service/client_hwid.go @@ -92,10 +92,12 @@ func normalizeHwidRequest(req HwidRequest) HwidRequest { } } +// The subscription body is built for disabled clients too, so an expired, +// depleted or suspended client must keep holding the limit of its subId. func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) { var limit int err := tx.Model(&model.ClientRecord{}). - Where("sub_id = ? AND enable = ?", subID, true). + Where("sub_id = ?", subID). Select("COALESCE(MAX(limit_hwid), 0)"). Scan(&limit).Error return limit, err @@ -216,7 +218,7 @@ func trackUnlimitedHwid(db *gorm.DB, subID string, req HwidRequest) { } // HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or -// last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced. +// last_seen. It shares the gate's limit query, so limit == limit enforced. func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) { subID = strings.TrimSpace(subID) if subID == "" { diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go index 2d960322a..11a3e13df 100644 --- a/internal/web/service/client_hwid_test.go +++ b/internal/web/service/client_hwid_test.go @@ -288,6 +288,48 @@ func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) { } } +func TestClientHwidGateHoldsWhileClientDisabled(t *testing.T) { + initClientHwidTestDB(t) + svc := &ClientService{} + db := database.GetDB() + rec := seedHwidClient(t, 1) + + if res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed { + t.Fatalf("register first device = %+v, %v", res, err) + } + if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("enable", false).Error; err != nil { + t.Fatalf("disable client: %v", err) + } + + assertStillLimited := func(stage string) { + t.Helper() + res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-two"}) + if err != nil { + t.Fatalf("%s: second device: %v", stage, err) + } + if res.Allowed || !res.MaxDevicesReached || res.Limit != 1 { + t.Fatalf("%s: disabled client must keep its device limit: %+v", stage, res) + } + if res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-one"}); err != nil || !res.Allowed { + t.Fatalf("%s: registered device = %+v, %v", stage, res, err) + } + var count int64 + if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil { + t.Fatalf("%s: count HWIDs: %v", stage, err) + } + if count != 1 { + t.Fatalf("%s: stored HWIDs = %d, want 1", stage, count) + } + } + assertStillLimited("only client disabled") + + // An enabled sibling without a limit must not lift it either. + if err := db.Create(&model.ClientRecord{Email: "second@example.com", SubID: rec.SubID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil { + t.Fatalf("seed second client: %v", err) + } + assertStillLimited("unlimited sibling enabled") +} + func TestClientHwidSlotStatus(t *testing.T) { initClientHwidTestDB(t) svc := &ClientService{}