From abd04681463b364edc92a91bce006fb6ff1f5a37 Mon Sep 17 00:00:00 2001 From: pg9182 <96569817+pg9182@users.noreply.github.com> Date: Sat, 25 Feb 2023 10:24:46 -0500 Subject: pkg/eax: Implement EAX API client --- pkg/eax/eax.go | 145 +++++++++++++++++++++++++++++++++++++++++ pkg/eax/updatemgr.go | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 pkg/eax/eax.go create mode 100644 pkg/eax/updatemgr.go diff --git a/pkg/eax/eax.go b/pkg/eax/eax.go new file mode 100644 index 0000000..34f5702 --- /dev/null +++ b/pkg/eax/eax.go @@ -0,0 +1,145 @@ +// Package eax queries the EA App API. +package eax + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strconv" +) + +type Client struct { + // The [net/http.Client] to use for requests. If not provided, + // [net/http.DefaultClient] is used. + Client *http.Client + + // The UpdateMgr for requests which require version information. + UpdateMgr *UpdateMgr +} + +var ErrVersionRequired = errors.New("client version is required for this endpoint") + +// PlayerID contains basic identifiers and names for a player. +type PlayerID struct { + PD uint64 // origin ID + PSD uint64 // ? + DisplayName string // in-game name + Nickname string // social name? +} + +// PlayerByPd gets basic information about an Origin ID. If the player does not +// exist, nil will be returned. +func (c *Client) PlayerIDByPD(ctx context.Context, pd uint64) (*PlayerID, error) { + var obj struct { + PlayerByPD *struct { + PD string `json:"pd"` + PSD string `json:"psd"` + DisplayName string `json:"displayName"` + Nickname string `json:"nickname"` + } `json:"playerByPd"` + } + if err := c.gql1(ctx, true, `query { playerByPd (pd: `+strconv.FormatUint(pd, 10)+`) { pd psd displayName nickname } }`, &obj); err != nil { + return nil, err + } + if obj.PlayerByPD == nil { + return nil, nil + } + res := &PlayerID{ + DisplayName: obj.PlayerByPD.DisplayName, + Nickname: obj.PlayerByPD.Nickname, + } + if s := obj.PlayerByPD.PD; s != "" { + if v, err := strconv.ParseUint(s, 10, 64); err == nil { + res.PD = v + } else { + return res, fmt.Errorf("parse pd %q: %w", s, err) + } + } + if s := obj.PlayerByPD.PSD; s != "" { + if v, err := strconv.ParseUint(s, 10, 64); err == nil { + res.PD = v + } else { + return res, fmt.Errorf("parse psd %q: %w", s, err) + } + } + return res, nil +} + +// gql1 performs a basic GraphQL query. +func (c *Client) gql1(ctx context.Context, ver bool, query string, out any) error { + req, err := c.req(ctx, ver, http.MethodGet, "https://service-aggregation-layer.juno.ea.com/graphql?query="+url.QueryEscape(query), nil) + if err != nil { + return err + } + + resp, err := c.do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "application/json" { + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("response status %d (%s) with content-type %q", resp.StatusCode, resp.Status, mt) + } + return fmt.Errorf("unexpected content-type %q", mt) + } + + buf, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var obj struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(buf, &obj); err != nil { + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("response status %d (%s) with invalid json", resp.StatusCode, resp.Status) + } + return fmt.Errorf("invalid json resp: %w", err) + } + if len(obj.Errors) != 0 { + return fmt.Errorf("got %d errors, including %q", len(obj.Errors), obj.Errors[0].Message) + } + if err := json.Unmarshal([]byte(obj.Data), out); err != nil { + return fmt.Errorf("invalid json data: %w", err) + } + return nil +} + +func (c *Client) do(r *http.Request) (*http.Response, error) { + if c.Client == nil { + return http.DefaultClient.Do(r) + } + return c.Client.Do(r) +} + +func (c *Client) req(ctx context.Context, ver bool, method, url string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err == nil { + if ver { + if c.UpdateMgr == nil { + return nil, ErrVersionRequired + } + ver, _, err := c.UpdateMgr.Update(false) + if err != nil { + return nil, fmt.Errorf("%w: failed to update version: %v", ErrVersionRequired, err) + } + if ver == "" { + return nil, ErrVersionRequired + } + req.Header.Set("User-Agent", "EADesktop/"+ver) + } + req.Header.Set("x-client-id", "EAX-JUNO-CLIENT") + } + return req, err +} diff --git a/pkg/eax/updatemgr.go b/pkg/eax/updatemgr.go new file mode 100644 index 0000000..d1a6a59 --- /dev/null +++ b/pkg/eax/updatemgr.go @@ -0,0 +1,180 @@ +package eax + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" +) + +// UpdateMgr manages EAX client version information. +type UpdateMgr struct { + // HTTP client to use. If not provided, [net/http.DefaultClient] will be + // used. + Client *http.Client + + // Timeout is the timeout for refreshing tokens. If zero, a reasonable + // default is used. If negative, there is no timeout. + Timeout time.Duration + + // Interval to update at. If zero, will not auto-update. + AutoUpdateInterval time.Duration + + // Auto-update staged roll-out bucket. + AutoUpdateBucket int + + // Auto-update backoff, if provided, checks if another auto-update is + // allowed after a failure. If it returns false, ErrAutoUpdateBackoff will be + // returned from the function triggering the auto-update. + AutoUpdateBackoff func(err error, time time.Time, count int) bool + + // AutoUpdateHook is called for every auto-update attempt with the new (or + // current if error) version, and any error which occurred. + AutoUpdateHook func(v string, err error) + + verInit sync.Once + verPf bool // ensures only one auto-update runs at a time + verCv *sync.Cond // allows other goroutines to wait for that update to complete + verErr error // last auto-update error + verErrTime time.Time // last auto-update error time + verErrCount int // consecutive auto-update errors + ver string // current version + verTime time.Time // last update check time +} + +var ErrAutoUpdateBackoff = errors.New("not updating eax client version due to backoff") + +func (u *UpdateMgr) init() { + u.verInit.Do(func() { + u.verCv = sync.NewCond(new(sync.Mutex)) + }) +} + +// SetVersion sets the current version. +func (u *UpdateMgr) SetVersion(v string) { + u.init() + u.verCv.L.Lock() + for u.verPf { + u.verCv.Wait() + } + u.ver = v + u.verErr = nil + u.verTime = time.Now() + u.verCv.L.Unlock() +} + +// Update gets the latest version, following u.AutoUpdateInterval if provided, +// unless the version is not set or force is true. If another update is in +// progress, it waits for the result of it. True is returned (on success or +// failure) if this call performed a update. This function may block for up to +// Timeout. +func (u *UpdateMgr) Update(force bool) (string, bool, error) { + u.init() + if u.verCv.L.Lock(); u.verPf { + for u.verPf { + u.verCv.Wait() + } + defer u.verCv.L.Unlock() + return u.ver, false, u.verErr + } else { + if force || u.ver == "" || (u.AutoUpdateInterval != 0 && time.Since(u.verTime) > u.AutoUpdateInterval) { + u.verPf = true + u.verCv.L.Unlock() + defer func() { + u.verCv.L.Lock() + u.verCv.Broadcast() + u.verPf = false + u.verCv.L.Unlock() + }() + } else { + defer u.verCv.L.Unlock() + return u.ver, false, u.verErr + } + } + if u.verErr != nil && u.AutoUpdateBackoff != nil { + if !u.AutoUpdateBackoff(u.verErr, u.verErrTime, u.verErrCount) { + return u.ver, true, fmt.Errorf("%w (%d attempts, last error: %v)", ErrAutoUpdateBackoff, u.verErrCount, u.verErr) + } + } + u.verErr = func() error { + var ctx context.Context + var cancel context.CancelFunc + if u.Timeout > 0 { + ctx, cancel = context.WithTimeout(context.Background(), u.Timeout) + } else if u.Timeout == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*15) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://autopatch.juno.ea.com/autopatch/upgrade/buckets/"+strconv.Itoa(u.AutoUpdateBucket), nil) + if err != nil { + return err + } + if u.ver != "" { + req.Header.Set("User-Agent", "EADesktop/"+u.ver) + } else { + req.Header.Set("User-Agent", "") + } + + cl := u.Client + if cl == nil { + cl = http.DefaultClient + } + + resp, err := cl.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var obj struct { + Minimum struct { + Version string `json:"version"` + ActivationDate string `json:"activationDate"` + } `json:"minimum"` + Recommended struct { + Version string `json:"version"` + } `json:"recommended"` + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("response status %d (%s)", resp.StatusCode, resp.Status) + } + if err := json.NewDecoder(resp.Body).Decode(&obj); err != nil { + return fmt.Errorf("parse autopatch response: %w", err) + } + + var version string + if v := obj.Minimum.Version; v != "" { + version = v + } + if v := obj.Recommended.Version; v != "" { + version = v + } + if version == "" { + return fmt.Errorf("parse autopatch response: missing minimum or recommended version") + } + u.ver = version + u.verTime = time.Now() + return nil + }() + if u.verErrCount != 0 { + u.verErr = fmt.Errorf("%w (attempt %d)", u.verErr, u.verErrCount) + } + if u.verErr != nil { + u.verErrCount++ + u.verErrTime = time.Now() + } else { + u.verErrCount = 0 + u.verErrTime = time.Time{} + } + if u.AutoUpdateHook != nil { + go u.AutoUpdateHook(u.ver, u.verErr) + } + return u.ver, true, u.verErr +} -- cgit v1.2.3