mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 04:17:15 +00:00
feat(sub): add per-client subscription HWID limits (#5802)
* feat(sub): add per-client subscription HWID limits * fix(sub): address HWID review on shared subId and bulk create * fix(sub): store HWID devices by sub_id and drop anchor client workaround * fix(sub): restore UA auto-detect and HTML page routing in subs() The cherry-pick of the HWID gate onto main's refactored SUBController had dropped main's UA-based format auto-detection and sub-page handling from subs(). Restore those branches, slotting enforceHwid after the HTML page and before format detection so the gate only applies to machine-readable subscription bodies. Also adapt tests to main's options-struct constructor and to the ClientService.Update signature extended with limitHwid. * fix(frontend): drop axios from HttpUtil.delete The bulk-delete rework's committed version still referenced axios, which this file no longer imports, breaking typecheck in CI. Use the httpRequest wrapper like the other verbs. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type HwidRequest struct {
|
||||
Hwid string
|
||||
UserAgent string
|
||||
DeviceOS string
|
||||
OsVersion string
|
||||
DeviceModel string
|
||||
}
|
||||
|
||||
type HwidGateResult struct {
|
||||
Allowed bool
|
||||
Active bool
|
||||
NotSupported bool
|
||||
MaxDevicesReached bool
|
||||
LimitReached bool
|
||||
Limit int
|
||||
Registered int
|
||||
}
|
||||
|
||||
const minHwidLength = 6
|
||||
|
||||
type ClientHwidInfo struct {
|
||||
Id int `json:"id"`
|
||||
FirstSeen int64 `json:"firstSeen"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
DeviceOS string `json:"deviceOs"`
|
||||
OsVersion string `json:"osVersion"`
|
||||
DeviceModel string `json:"deviceModel"`
|
||||
}
|
||||
|
||||
func hashHwid(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func trimHwidMeta(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
r := []rune(s)
|
||||
if len(r) > 512 {
|
||||
return string(r[:512])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeHwidRequest(req HwidRequest) HwidRequest {
|
||||
return HwidRequest{
|
||||
Hwid: strings.TrimSpace(req.Hwid),
|
||||
UserAgent: trimHwidMeta(req.UserAgent),
|
||||
DeviceOS: trimHwidMeta(req.DeviceOS),
|
||||
OsVersion: trimHwidMeta(req.OsVersion),
|
||||
DeviceModel: trimHwidMeta(req.DeviceModel),
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) {
|
||||
var limit int
|
||||
err := tx.Model(&model.ClientRecord{}).
|
||||
Where("sub_id = ? AND enable = ?", subID, true).
|
||||
Select("COALESCE(MAX(limit_hwid), 0)").
|
||||
Scan(&limit).Error
|
||||
return limit, err
|
||||
}
|
||||
|
||||
func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (HwidGateResult, error) {
|
||||
var res HwidGateResult
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" {
|
||||
res.Allowed = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
limit, err := effectiveHwidLimitForSubID(db, subID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
res.Allowed = true
|
||||
return res, nil
|
||||
}
|
||||
|
||||
req = normalizeHwidRequest(req)
|
||||
res.Active = true
|
||||
res.Limit = limit
|
||||
if len(req.Hwid) < minHwidLength {
|
||||
res.NotSupported = true
|
||||
return res, nil
|
||||
}
|
||||
hwidHash := hashHwid(req.Hwid)
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
limit, err := effectiveHwidLimitForSubID(tx, subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if limit <= 0 {
|
||||
res = HwidGateResult{Allowed: true}
|
||||
return nil
|
||||
}
|
||||
res.Active = true
|
||||
res.Limit = limit
|
||||
now := time.Now().UnixMilli()
|
||||
var existing model.ClientHwid
|
||||
err = tx.Where("sub_id = ? AND hwid_hash = ?", subID, hwidHash).First(&existing).Error
|
||||
if err == nil {
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("id = ?", existing.Id).Updates(map[string]any{
|
||||
"last_seen": now, "user_agent": req.UserAgent, "device_os": req.DeviceOS, "os_version": req.OsVersion, "device_model": req.DeviceModel,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Allowed = true
|
||||
res.Registered = int(count)
|
||||
res.LimitReached = count >= int64(limit)
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Registered = int(count)
|
||||
if count >= int64(limit) {
|
||||
res.MaxDevicesReached = true
|
||||
res.LimitReached = true
|
||||
return nil
|
||||
}
|
||||
if err := tx.Create(&model.ClientHwid{SubID: subID, HwidHash: hwidHash, FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Allowed = true
|
||||
res.Registered = int(count) + 1
|
||||
res.LimitReached = res.Registered >= limit
|
||||
return nil
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (s *ClientService) ListClientHwids(email string) ([]ClientHwidInfo, error) {
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []model.ClientHwid
|
||||
if err := database.GetDB().
|
||||
Where("sub_id = ?", subID).
|
||||
Order("last_seen DESC").
|
||||
Order("id DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ClientHwidInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ClientHwidInfo{
|
||||
Id: r.Id,
|
||||
FirstSeen: r.FirstSeen,
|
||||
LastSeen: r.LastSeen,
|
||||
UserAgent: r.UserAgent,
|
||||
DeviceOS: r.DeviceOS,
|
||||
OsVersion: r.OsVersion,
|
||||
DeviceModel: r.DeviceModel,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ClearClientHwids(email string) error {
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
|
||||
func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
var rec model.ClientRecord
|
||||
if err := tx.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("limit_hwid", limit).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
subID := strings.TrimSpace(rec.SubID)
|
||||
if subID == "" {
|
||||
return nil
|
||||
}
|
||||
effective, err := effectiveHwidLimitForSubID(tx, subID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return trimClientHwidsForSubID(tx, subID, effective)
|
||||
}
|
||||
|
||||
func trimClientHwidsForSubID(tx *gorm.DB, subID string, limit int) error {
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
var keep []int
|
||||
if err := tx.Model(&model.ClientHwid{}).
|
||||
Where("sub_id = ?", subID).
|
||||
Order("last_seen DESC").
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Pluck("id", &keep).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
return tx.Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
return tx.Where("sub_id = ? AND id NOT IN ?", subID, keep).Delete(&model.ClientHwid{}).Error
|
||||
}
|
||||
|
||||
func clearClientHwidsBySubIDTx(tx *gorm.DB, subIDs ...string) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
clean := make([]string, 0, len(subIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, subID := range subIDs {
|
||||
subID = strings.TrimSpace(subID)
|
||||
if subID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[subID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[subID] = struct{}{}
|
||||
clean = append(clean, subID)
|
||||
}
|
||||
for _, batch := range chunkStrings(clean, sqlInChunk) {
|
||||
if err := tx.Where("sub_id IN ?", batch).Delete(&model.ClientHwid{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user