Files
navi/internal/remnawave/userinfo.go
T

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
}