mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-22 02:47:14 +00:00
1250fbb734
* feat(clients): allow removing a single HWID device Only "list" and "clear all" existed for registered HWID devices, so freeing one slot under a client's HWID limit meant clearing every device and waiting for the ones you kept to re-register. Adds a per-device delete: DELETE /panel/api/clients/hwids/:email/:id, scoped to the client's own sub_id (device ids are a global auto-increment, not per-subID, so this also prevents deleting another client's device), plus a delete button next to each device in the existing HWID modal. Addresses MHSanaei/3x-ui#6245. * feat(clients): surface HWID limit + device log in the client info card Mirrors the existing IP-limit row/eye-icon-modal pattern that's already in this card. The HWID devices modal reuses the same list/clear-all/per-device-delete UI already shipped for the edit form's own HWID modal, so a device can be removed without opening the edit form at all. * i18n: add HWID single-delete strings to all 13 locales deleteHwid/deleteHwidConfirm/hwidDeleted were only added to en-US and ru-RU in the previous commit; backfilling the other 11 locales the project's own translation set covers. * fix(clients): address automated review of HWID single-delete PR - ClientInfoModal: use the existing dateLabel() helper (Jalali-aware) for HWID first/last-seen instead of a raw dayjs format, matching every other timestamp in the same modal. - Add okText/cancelText to the delete-device Popconfirm in both ClientInfoModal and ClientFormModal so all 13 locales get a translated confirm dialog instead of Antd's English default. - deleteHwid controller: stop reusing the success toast key on both error paths, which rendered a red "Update successful" toast on a real (not just theoretical) failure such as a stale HWID modal. - Trim DeleteClientHwid's doc comment to the repo's 2-line cap and correct it: deletion is scoped by sub_id, which can span more than one ClientRecord, not strictly "this client only". - Add TestDeleteClientHwid covering cross-sub_id id rejection, unknown id rejection, and a real successful delete. * chore: retrigger CI (previous run stuck installing Playwright Chromium) * fix(clients): address the arbiter review on the HWID single-delete PR - Extract the HWID device list into a shared frontend/src/lib/clients/ hwid-log.ts type/normalizer, a shared useClientHwids hook, and a shared ClientHwidListModal component, mirroring the existing IP-log pattern. ClientInfoModal and ClientFormModal both render the same component now, so the two copies can no longer drift the way they already had (different date formatting, different tag styles). - Add a Popconfirm to the HWID "Clear all" button (previously unconfirmed, unlike the per-device delete right next to it) — closes the confirm/no-confirm asymmetry the review flagged as the main risk. - Sync docs/public/openapi.json with the two hwids paths and regenerate clients.mdx. Scoped to just those two paths rather than a full copy from frontend/public/openapi.json: the docs copy is far enough behind on unrelated paths (a host-group API rename) that a full sync breaks the Next.js build on locale pages referencing the old shape — out of scope for this PR. * fix(clients): trim HWID list comment blocks to 2 lines Repo convention caps comment blocks at 2 lines; both were 1 line over. * chore: retrigger CI build (arm64) and build (armv6) failed on a transient Go module proxy network error (INTERNAL_ERROR stream reset), unrelated to this PR's changes.
293 lines
7.3 KiB
Go
293 lines
7.3 KiB
Go
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
|
|
}
|
|
|
|
// DeleteClientHwid removes one device, scoped to the client's sub_id: ids
|
|
// are a global auto-increment, so an id outside this subscription won't match.
|
|
func (s *ClientService) DeleteClientHwid(email string, id int) error {
|
|
rec, err := s.GetRecordByEmail(nil, email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
subID := strings.TrimSpace(rec.SubID)
|
|
if subID == "" {
|
|
return errors.New("client has no subscription id")
|
|
}
|
|
res := database.GetDB().Where("sub_id = ? AND id = ?", subID, id).Delete(&model.ClientHwid{})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("device not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|