123 lines
3.5 KiB
Go
123 lines
3.5 KiB
Go
package remnawave
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// UserInfo is subscription usage metadata from the Remnawave admin API
|
|
// (GET /api/users/by-short-uuid/{short} + GET /api/hwid/devices/{uuid}).
|
|
type UserInfo struct {
|
|
UsedTraffic int64 // bytes
|
|
TrafficLimit int64 // bytes; 0 = unlimited
|
|
ExpireAt int64 // unix seconds; 0 = unknown
|
|
DeviceLimit int // 0 = no limit / unknown
|
|
Devices int
|
|
DevicesKnown bool
|
|
}
|
|
|
|
// FetchUserInfo loads traffic/expiry and HWID device info via the admin API.
|
|
// Requires BaseURL, ShortUUID and Token; degrade gracefully — the caller
|
|
// should treat any error as "info unavailable".
|
|
func FetchUserInfo(ctx context.Context, s Settings) (*UserInfo, error) {
|
|
s.BaseURL = NormalizeBase(s.BaseURL)
|
|
s.ShortUUID = strings.TrimSpace(s.ShortUUID)
|
|
s.Token = strings.TrimSpace(s.Token)
|
|
if s.BaseURL == "" || s.ShortUUID == "" {
|
|
return nil, fmt.Errorf("нет URL панели / short UUID")
|
|
}
|
|
if s.Token == "" {
|
|
return nil, fmt.Errorf("нет API токена Remnawave")
|
|
}
|
|
|
|
client := &http.Client{Timeout: 20 * time.Second}
|
|
endpoint := s.BaseURL + "/api/users/by-short-uuid/" + url.PathEscape(s.ShortUUID)
|
|
body, _, err := doGET(ctx, client, s, endpoint, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload struct {
|
|
Response userDTO `json:"response"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &payload); err != nil {
|
|
// Some deployments return the DTO without the {"response": ...} envelope.
|
|
var direct userDTO
|
|
if err2 := json.Unmarshal([]byte(body), &direct); err2 != nil {
|
|
return nil, fmt.Errorf("Remnawave user info: %w", err)
|
|
}
|
|
payload.Response = direct
|
|
}
|
|
u := payload.Response
|
|
if u.UUID == "" && u.UsedTrafficBytes == 0 && u.ExpireAt == "" {
|
|
return nil, fmt.Errorf("Remnawave: пустой ответ user info")
|
|
}
|
|
|
|
info := &UserInfo{
|
|
UsedTraffic: u.UsedTrafficBytes,
|
|
TrafficLimit: u.TrafficLimitBytes,
|
|
DeviceLimit: u.HwidDeviceLimit,
|
|
}
|
|
if u.ExpireAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, u.ExpireAt); err == nil {
|
|
info.ExpireAt = t.Unix()
|
|
}
|
|
}
|
|
|
|
if u.UUID != "" {
|
|
if total, ok := fetchDeviceCount(ctx, client, s, u.UUID); ok {
|
|
info.Devices = total
|
|
info.DevicesKnown = true
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
type userDTO struct {
|
|
UUID string `json:"uuid"`
|
|
UsedTrafficBytes int64 `json:"usedTrafficBytes"`
|
|
TrafficLimitBytes int64 `json:"trafficLimitBytes"`
|
|
ExpireAt string `json:"expireAt"`
|
|
HwidDeviceLimit int `json:"hwidDeviceLimit"`
|
|
}
|
|
|
|
// fetchDeviceCount returns the number of HWID devices attached to a user.
|
|
// Handles both response shapes: {"response":{"total":N,"devices":[...]}} and
|
|
// the legacy {"response":[...]}.
|
|
func fetchDeviceCount(ctx context.Context, client *http.Client, s Settings, userUUID string) (int, bool) {
|
|
endpoint := s.BaseURL + "/api/hwid/devices/" + url.PathEscape(userUUID)
|
|
body, _, err := doGET(ctx, client, s, endpoint, true)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
var wrapped struct {
|
|
Response json.RawMessage `json:"response"`
|
|
}
|
|
raw := json.RawMessage(body)
|
|
if err := json.Unmarshal([]byte(body), &wrapped); err == nil && len(wrapped.Response) > 0 {
|
|
raw = wrapped.Response
|
|
}
|
|
var obj struct {
|
|
Total *int `json:"total"`
|
|
Devices []json.RawMessage `json:"devices"`
|
|
}
|
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
|
if obj.Total != nil {
|
|
return *obj.Total, true
|
|
}
|
|
if obj.Devices != nil {
|
|
return len(obj.Devices), true
|
|
}
|
|
}
|
|
var arr []json.RawMessage
|
|
if err := json.Unmarshal(raw, &arr); err == nil {
|
|
return len(arr), true
|
|
}
|
|
return 0, false
|
|
}
|