322 lines
7.8 KiB
Go
322 lines
7.8 KiB
Go
package coinglass
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/enetx/g"
|
|
"github.com/enetx/surf"
|
|
"github.com/pquerna/otp/totp"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://capi.coinglass.com"
|
|
defaultOrigin = "https://www.coinglass.com"
|
|
defaultDataAESKey = "1f68efd73f8d4921acc0dead41dd39bc"
|
|
defaultRequestTimeout = 15 * time.Second
|
|
defaultFakeSuccessRetry = 1
|
|
defaultFakeSuccessRetryWait = 2 * time.Second
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
totpSecret string
|
|
dataAESKey []byte
|
|
fakeSuccessRetry int
|
|
fakeSuccessRetryWait time.Duration
|
|
doer requestDoer
|
|
now func() time.Time
|
|
}
|
|
|
|
type ClientOptions struct {
|
|
BaseURL string
|
|
TOTPSecret string
|
|
DataAESKey string
|
|
Timeout time.Duration
|
|
FakeSuccessRetry int
|
|
FakeSuccessRetryWait time.Duration
|
|
}
|
|
|
|
type requestDoer interface {
|
|
Do(context.Context, httpRequest) (*httpResponse, error)
|
|
}
|
|
|
|
type httpRequest struct {
|
|
URL string
|
|
Header http.Header
|
|
}
|
|
|
|
type httpResponse struct {
|
|
StatusCode int
|
|
Header http.Header
|
|
Body []byte
|
|
}
|
|
|
|
type transportEnvelope struct {
|
|
Success *bool `json:"success"`
|
|
Code json.RawMessage `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data *string `json:"data"`
|
|
}
|
|
|
|
func NewClient(opts ClientOptions) (*Client, error) {
|
|
return newClientWithDoer(opts, newSurfDoer(opts.Timeout))
|
|
}
|
|
|
|
func newClientWithDoer(opts ClientOptions, doer requestDoer) (*Client, error) {
|
|
if doer == nil {
|
|
return nil, fmt.Errorf("coinglass client: nil doer")
|
|
}
|
|
if opts.BaseURL == "" {
|
|
opts.BaseURL = defaultBaseURL
|
|
}
|
|
if opts.TOTPSecret == "" {
|
|
return nil, fmt.Errorf("coinglass client: totp secret is required")
|
|
}
|
|
if opts.DataAESKey == "" {
|
|
opts.DataAESKey = defaultDataAESKey
|
|
}
|
|
if opts.FakeSuccessRetry == 0 {
|
|
opts.FakeSuccessRetry = defaultFakeSuccessRetry
|
|
}
|
|
if opts.FakeSuccessRetryWait == 0 {
|
|
opts.FakeSuccessRetryWait = defaultFakeSuccessRetryWait
|
|
}
|
|
|
|
if _, err := aesECBEncrypt([]byte(opts.DataAESKey), pkcs7Pad([]byte("probe"), 16)); err != nil {
|
|
return nil, fmt.Errorf("coinglass client: bad data aes key: %w", err)
|
|
}
|
|
|
|
return &Client{
|
|
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
|
totpSecret: opts.TOTPSecret,
|
|
dataAESKey: []byte(opts.DataAESKey),
|
|
fakeSuccessRetry: opts.FakeSuccessRetry,
|
|
fakeSuccessRetryWait: opts.FakeSuccessRetryWait,
|
|
doer: doer,
|
|
now: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
// GetPlaintext dispatches one CoinGlass V2 GET request and returns decrypted plaintext bytes.
|
|
func (c *Client) GetPlaintext(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
|
var lastFake error
|
|
for attempt := 0; attempt <= c.fakeSuccessRetry; attempt++ {
|
|
plain, err := c.getPlaintextOnce(ctx, path, query)
|
|
if err == nil {
|
|
return plain, nil
|
|
}
|
|
if !errors.Is(err, ErrFakeSuccess) {
|
|
return nil, err
|
|
}
|
|
lastFake = err
|
|
if attempt == c.fakeSuccessRetry {
|
|
break
|
|
}
|
|
if err := sleepContext(ctx, c.fakeSuccessRetryWait); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, lastFake
|
|
}
|
|
|
|
func (c *Client) getPlaintextOnce(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
|
now := c.now()
|
|
cacheTsV2 := fmt.Sprintf("%d", now.UnixMilli())
|
|
|
|
q := cloneValues(query)
|
|
dataParam, err := generateDataParam(c.totpSecret, c.dataAESKey, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q.Set("data", dataParam)
|
|
|
|
resp, err := c.doer.Do(ctx, httpRequest{
|
|
URL: c.buildURL(path, q),
|
|
Header: baseHeaders(cacheTsV2),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("coinglass %s status=%d: %s", path, resp.StatusCode, bodySnippet(resp.Body))
|
|
}
|
|
|
|
env, err := parseTransportEnvelope(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("coinglass %s envelope: %w", path, err)
|
|
}
|
|
if env.Success != nil && !*env.Success {
|
|
return nil, fmt.Errorf("%w: code=%s msg=%q", ErrAPIBusiness, envelopeCode(env.Code), env.Msg)
|
|
}
|
|
if env.Data == nil || *env.Data == "" {
|
|
return nil, fmt.Errorf("%w: empty data code=%s msg=%q", ErrFakeSuccess, envelopeCode(env.Code), env.Msg)
|
|
}
|
|
|
|
v := firstHeader(resp.Header, "v")
|
|
user := firstHeader(resp.Header, "user")
|
|
respTime := firstHeader(resp.Header, "time")
|
|
if v == "" || user == "" {
|
|
return nil, fmt.Errorf("%w: missing v/user response headers", ErrFakeSuccess)
|
|
}
|
|
|
|
seed, err := resolveSeed(v, path, cacheTsV2, respTime)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plain, err := DecryptUniversal(seed, user, *env.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plain, nil
|
|
}
|
|
|
|
func (c *Client) buildURL(path string, query url.Values) string {
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
if len(query) == 0 {
|
|
return c.baseURL + path
|
|
}
|
|
return c.baseURL + path + "?" + query.Encode()
|
|
}
|
|
|
|
func generateDataParam(secret string, key []byte, now time.Time) (string, error) {
|
|
code, err := totp.GenerateCode(secret, now)
|
|
if err != nil {
|
|
return "", fmt.Errorf("coinglass data param totp: %w", err)
|
|
}
|
|
plain := []byte(fmt.Sprintf("%d,%s", now.Unix(), code))
|
|
enc, err := aesECBEncrypt(key, pkcs7Pad(plain, 16))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(enc), nil
|
|
}
|
|
|
|
func baseHeaders(cacheTsV2 string) http.Header {
|
|
h := http.Header{}
|
|
h.Set("Accept", "application/json")
|
|
h.Set("Accept-Language", "en-US,en;q=0.9")
|
|
h.Set("Language", "zh")
|
|
h.Set("Encryption", "true")
|
|
h.Set("Origin", defaultOrigin)
|
|
h.Set("Referer", defaultOrigin+"/")
|
|
h.Set("Cache-Ts-V2", cacheTsV2)
|
|
h.Set("Sec-Fetch-Dest", "empty")
|
|
h.Set("Sec-Fetch-Mode", "cors")
|
|
h.Set("Sec-Fetch-Site", "same-site")
|
|
return h
|
|
}
|
|
|
|
func parseTransportEnvelope(body []byte) (*transportEnvelope, error) {
|
|
var env transportEnvelope
|
|
if err := json.Unmarshal(body, &env); err != nil {
|
|
return nil, err
|
|
}
|
|
return &env, nil
|
|
}
|
|
|
|
func cloneValues(in url.Values) url.Values {
|
|
out := make(url.Values, len(in))
|
|
for k, vs := range in {
|
|
out[k] = append([]string(nil), vs...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sleepContext(ctx context.Context, d time.Duration) error {
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func firstHeader(h http.Header, key string) string {
|
|
if v := h.Get(key); v != "" {
|
|
return v
|
|
}
|
|
return h.Get(http.CanonicalHeaderKey(key))
|
|
}
|
|
|
|
func envelopeCode(raw json.RawMessage) string {
|
|
raw = json.RawMessage(strings.TrimSpace(string(raw)))
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return ""
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return s
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
func bodySnippet(b []byte) string {
|
|
const max = 200
|
|
if len(b) <= max {
|
|
return string(b)
|
|
}
|
|
return string(b[:max])
|
|
}
|
|
|
|
type surfDoer struct {
|
|
client *surf.Client
|
|
}
|
|
|
|
func newSurfDoer(timeout time.Duration) *surfDoer {
|
|
if timeout == 0 {
|
|
timeout = defaultRequestTimeout
|
|
}
|
|
return &surfDoer{
|
|
client: surf.NewClient().Builder().
|
|
Impersonate().FireFox().
|
|
Timeout(timeout).
|
|
Build(),
|
|
}
|
|
}
|
|
|
|
func (d *surfDoer) Do(ctx context.Context, req httpRequest) (*httpResponse, error) {
|
|
result := d.client.Get(g.String(req.URL)).
|
|
WithContext(ctx).
|
|
SetHeaders(singleHeaderValues(req.Header)).
|
|
Do()
|
|
if result.IsErr() {
|
|
return nil, result.Err()
|
|
}
|
|
|
|
resp := result.Ok()
|
|
header := http.Header{}
|
|
for _, key := range []string{"v", "user", "time"} {
|
|
if value := resp.Headers.Get(g.String(key)).Std(); value != "" {
|
|
header.Set(key, value)
|
|
}
|
|
}
|
|
|
|
return &httpResponse{
|
|
StatusCode: int(resp.StatusCode),
|
|
Header: header,
|
|
Body: []byte(resp.Body.Bytes()),
|
|
}, nil
|
|
}
|
|
|
|
func singleHeaderValues(h http.Header) map[string]string {
|
|
out := make(map[string]string, len(h))
|
|
for k, vs := range h {
|
|
if len(vs) > 0 {
|
|
out[k] = vs[0]
|
|
}
|
|
}
|
|
return out
|
|
}
|