mirror of
https://github.com/sstent/go-garth-cli.git
synced 2025-12-06 08:02:01 +00:00
616 lines
15 KiB
Go
616 lines
15 KiB
Go
package connect
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// ErrForbidden will be returned if the client doesn't have access to the
|
|
// requested ressource.
|
|
ErrForbidden = Error("forbidden")
|
|
|
|
// ErrNotFound will be returned if the requested ressource could not be
|
|
// found.
|
|
ErrNotFound = Error("not found")
|
|
|
|
// ErrBadRequest will be returned if Garmin returned a status code 400.
|
|
ErrBadRequest = Error("bad request")
|
|
|
|
// ErrNoCredentials will be returned if credentials are needed - but none
|
|
// are set.
|
|
ErrNoCredentials = Error("no credentials set")
|
|
|
|
// ErrNotAuthenticated will be returned is the client is not
|
|
// authenticated as required by the request. Remember to call
|
|
// Authenticate().
|
|
ErrNotAuthenticated = Error("client is not authenticated")
|
|
|
|
// ErrWrongCredentials will be returned if the username and/or
|
|
// password is not recognized by Garmin Connect.
|
|
ErrWrongCredentials = Error("username and/or password not recognized")
|
|
)
|
|
|
|
const (
|
|
// sessionCookieName is the magic session cookie name.
|
|
sessionCookieName = "SESSIONID"
|
|
|
|
// cflbCookieName is the cookie used by Cloudflare to pin the request
|
|
// to a specific backend.
|
|
cflbCookieName = "__cflb"
|
|
)
|
|
|
|
// Client can be used to access the unofficial Garmin Connect API.
|
|
type Client struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
SessionID string `json:"sessionID"`
|
|
Profile *SocialProfile `json:"socialProfile"`
|
|
|
|
// LoadBalancerID is the load balancer ID set by Cloudflare in front of
|
|
// Garmin Connect. This must be preserves across requests. A session key
|
|
// is only valid with a corresponding loadbalancer key.
|
|
LoadBalancerID string `json:"cflb"`
|
|
|
|
client *http.Client
|
|
autoRenewSession bool
|
|
debugLogger Logger
|
|
dumpWriter io.Writer
|
|
}
|
|
|
|
// Option is the type to set options on the client.
|
|
type Option func(*Client)
|
|
|
|
// SessionID will set a predefined session ID. This can be useful for clients
|
|
// keeping state. A few HTTP roundtrips can be saved, if the session ID is
|
|
// reused. And some load would be taken of Garmin servers. This must be
|
|
// accompanied by LoadBalancerID.
|
|
// Generally this should not be used. Users of this package should save
|
|
// all exported fields from Client and re-use those at a later request.
|
|
// json.Marshal() and json.Unmarshal() can be used.
|
|
func SessionID(sessionID string) Option {
|
|
return func(c *Client) {
|
|
c.SessionID = sessionID
|
|
}
|
|
}
|
|
|
|
// LoadBalancerID will set a load balancer ID. This is used by Garmin load
|
|
// balancers to route subsequent requests to the same backend server.
|
|
func LoadBalancerID(loadBalancerID string) Option {
|
|
return func(c *Client) {
|
|
c.LoadBalancerID = loadBalancerID
|
|
}
|
|
}
|
|
|
|
// Credentials can be used to pass login credentials to NewClient.
|
|
func Credentials(email string, password string) Option {
|
|
return func(c *Client) {
|
|
c.Email = email
|
|
c.Password = password
|
|
}
|
|
}
|
|
|
|
// AutoRenewSession will set if the session should be autorenewed upon expire.
|
|
// Default is true.
|
|
func AutoRenewSession(autoRenew bool) Option {
|
|
return func(c *Client) {
|
|
c.autoRenewSession = autoRenew
|
|
}
|
|
}
|
|
|
|
// DebugLogger is used to set a debug logger.
|
|
func DebugLogger(logger Logger) Option {
|
|
return func(c *Client) {
|
|
c.debugLogger = logger
|
|
}
|
|
}
|
|
|
|
// DumpWriter will instruct Client to dump all HTTP requests and responses to
|
|
// and from Garmin to w.
|
|
func DumpWriter(w io.Writer) Option {
|
|
return func(c *Client) {
|
|
c.dumpWriter = w
|
|
}
|
|
}
|
|
|
|
// NewClient returns a new client for accessing the unofficial Garmin Connect
|
|
// API.
|
|
func NewClient(options ...Option) *Client {
|
|
client := &Client{
|
|
client: &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
// To avoid a Cloudflare error, we have to use TLS 1.1 or 1.2.
|
|
MinVersion: tls.VersionTLS11,
|
|
MaxVersion: tls.VersionTLS12,
|
|
},
|
|
},
|
|
},
|
|
autoRenewSession: true,
|
|
debugLogger: &discardLog{},
|
|
dumpWriter: nil,
|
|
}
|
|
|
|
client.SetOptions(options...)
|
|
|
|
return client
|
|
}
|
|
|
|
// SetOptions can be used to set various options on Client.
|
|
func (c *Client) SetOptions(options ...Option) {
|
|
for _, option := range options {
|
|
option(c)
|
|
}
|
|
}
|
|
|
|
func (c *Client) dump(reqResp interface{}) {
|
|
if c.dumpWriter == nil {
|
|
return
|
|
}
|
|
|
|
var dump []byte
|
|
switch obj := reqResp.(type) {
|
|
case *http.Request:
|
|
_, _ = c.dumpWriter.Write([]byte("\n\nREQUEST\n"))
|
|
dump, _ = httputil.DumpRequestOut(obj, true)
|
|
case *http.Response:
|
|
_, _ = c.dumpWriter.Write([]byte("\n\nRESPONSE\n"))
|
|
dump, _ = httputil.DumpResponse(obj, true)
|
|
default:
|
|
panic("unsupported type")
|
|
}
|
|
|
|
_, _ = c.dumpWriter.Write(dump)
|
|
}
|
|
|
|
// addCookies adds needed cookies to a http request if the values are known.
|
|
func (c *Client) addCookies(req *http.Request) {
|
|
if c.SessionID != "" {
|
|
req.AddCookie(&http.Cookie{
|
|
Value: c.SessionID,
|
|
Name: sessionCookieName,
|
|
})
|
|
}
|
|
|
|
if c.LoadBalancerID != "" {
|
|
req.AddCookie(&http.Cookie{
|
|
Value: c.LoadBalancerID,
|
|
Name: cflbCookieName,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (c *Client) newRequest(method string, url string, body io.Reader) (*http.Request, error) {
|
|
req, err := http.NewRequest(method, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Play nice and give Garmin engineers a way to contact us.
|
|
req.Header.Set("User-Agent", "github.com/abrander/garmin-connect")
|
|
|
|
// Yep. This is needed for requests sent to the API. No idea what it does.
|
|
req.Header.Add("nk", "NT")
|
|
|
|
c.addCookies(req)
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) getJSON(url string, target interface{}) error {
|
|
req, err := c.newRequest("GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
|
|
return decoder.Decode(target)
|
|
}
|
|
|
|
// write is suited for writing stuff to the API when you're NOT expected any
|
|
// data in return but a HTTP status code.
|
|
func (c *Client) write(method string, url string, payload interface{}, expectedStatus int) error {
|
|
var body io.Reader
|
|
|
|
if payload != nil {
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body = bytes.NewReader(b)
|
|
}
|
|
|
|
req, err := c.newRequest(method, url, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If we have a payload it is by definition JSON.
|
|
if payload != nil {
|
|
req.Header.Add("content-type", "application/json")
|
|
}
|
|
|
|
resp, err := c.do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if expectedStatus > 0 && resp.StatusCode != expectedStatus {
|
|
return fmt.Errorf("HTTP %s returned %d (%d expected)", method, resp.StatusCode, expectedStatus)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleForbidden will try to extract an error message from the response.
|
|
func (c *Client) handleForbidden(resp *http.Response) error {
|
|
defer resp.Body.Close()
|
|
|
|
type proxy struct {
|
|
Message string `json:"message"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
|
|
var errorMessage proxy
|
|
|
|
err := decoder.Decode(&errorMessage)
|
|
if err == nil && errorMessage.Message != "" {
|
|
return Error(errorMessage.Message)
|
|
}
|
|
|
|
return ErrForbidden
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request) (*http.Response, error) {
|
|
c.debugLogger.Printf("Requesting %s at %s", req.Method, req.URL.String())
|
|
|
|
// Save the body in case we need to replay the request.
|
|
var save io.ReadCloser
|
|
var err error
|
|
if req.Body != nil {
|
|
save, req.Body, err = drainBody(req.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
c.dump(req)
|
|
t0 := time.Now()
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.dump(resp)
|
|
|
|
// This is exciting. If the user does not have permission to access a
|
|
// ressource, the API will return an ApplicationException and return a
|
|
// 403 status code.
|
|
// If the session is invalid, the Garmin API will return the same exception
|
|
// and status code (!).
|
|
// To distinguish between these two error cases, we look for a new session
|
|
// cookie in the response. If a new session cookies is set by Garmin, we
|
|
// assume our current session is invalid.
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == sessionCookieName {
|
|
resp.Body.Close()
|
|
c.debugLogger.Printf("Session invalid, requesting new session")
|
|
|
|
// Wups. Our session got invalidated.
|
|
c.SetOptions(SessionID(""))
|
|
c.SetOptions(LoadBalancerID(""))
|
|
|
|
// Re-new session.
|
|
err = c.Authenticate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.debugLogger.Printf("Successfully authenticated as %s", c.Email)
|
|
|
|
// Replace the drained body
|
|
req.Body = save
|
|
|
|
// Replace the cookie ned newRequest with the new sessionid and load balancer key.
|
|
req.Header.Del("Cookie")
|
|
c.addCookies(req)
|
|
|
|
c.debugLogger.Printf("Replaying %s request to %s", req.Method, req.URL.String())
|
|
|
|
c.dump(req)
|
|
|
|
// Replay the original request only once, if we fail twice
|
|
// something is rotten, and we should give up.
|
|
t0 = time.Now()
|
|
resp, err = c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.dump(resp)
|
|
}
|
|
}
|
|
|
|
c.debugLogger.Printf("Got HTTP status code %d in %s", resp.StatusCode, time.Since(t0).String())
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusBadRequest:
|
|
resp.Body.Close()
|
|
return nil, ErrBadRequest
|
|
case http.StatusForbidden:
|
|
return nil, c.handleForbidden(resp)
|
|
case http.StatusNotFound:
|
|
resp.Body.Close()
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
// Download will retrieve a file from url using Garmin Connect credentials.
|
|
// It's mostly useful when developing new features or debugging existing
|
|
// ones.
|
|
// Please note that this will pass the Garmin session cookie to the URL
|
|
// provided. Only use this for endpoints on garmin.com.
|
|
func (c *Client) Download(url string, w io.Writer) error {
|
|
req, err := c.newRequest("GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
_, err = io.Copy(w, resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) authenticated() bool {
|
|
return c.SessionID != ""
|
|
}
|
|
|
|
// Authenticate using a Garmin Connect username and password provided by
|
|
// the Credentials option function.
|
|
func (c *Client) Authenticate() error {
|
|
// We cannot use Client.do() in this function, since this function can be
|
|
// called from do() upon session renewal.
|
|
URL := "https://sso.garmin.com/sso/signin" +
|
|
"?service=https%3A%2F%2Fconnect.garmin.com%2Fmodern%2F" +
|
|
"&gauthHost=https%3A%2F%2Fconnect.garmin.com%2Fmodern%2F" +
|
|
"&generateExtraServiceTicket=true" +
|
|
"&generateTwoExtraServiceTickets=true"
|
|
|
|
if c.Email == "" || c.Password == "" {
|
|
return ErrNoCredentials
|
|
}
|
|
|
|
c.debugLogger.Printf("Getting CSRF token at %s", URL)
|
|
|
|
// Start by getting CSRF token.
|
|
req, err := http.NewRequest("GET", URL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.dump(req)
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.dump(resp)
|
|
|
|
csrfToken, err := extractCSRFToken(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp.Body.Close()
|
|
|
|
c.debugLogger.Printf("Got CSRF token: '%s'", csrfToken)
|
|
|
|
c.debugLogger.Printf("Trying credentials at %s", URL)
|
|
|
|
formValues := url.Values{
|
|
"username": {c.Email},
|
|
"password": {c.Password},
|
|
"embed": {"false"},
|
|
"_csrf": {csrfToken},
|
|
}
|
|
|
|
req, err = c.newRequest("POST", URL, strings.NewReader(formValues.Encode()))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Referer", URL)
|
|
|
|
c.dump(req)
|
|
|
|
resp, err = c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.dump(resp)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
|
|
return fmt.Errorf("Garmin SSO returned \"%s\"", resp.Status)
|
|
}
|
|
|
|
body, _ := ioutil.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
// Extract ticket URL
|
|
t := regexp.MustCompile(`https:\\\/\\\/connect.garmin.com\\\/modern\\\/\?ticket=(([a-zA-Z0-9]|-)*)`)
|
|
ticketURL := t.FindString(string(body))
|
|
|
|
// undo escaping
|
|
ticketURL = strings.Replace(ticketURL, "\\/", "/", -1)
|
|
|
|
if ticketURL == "" {
|
|
return ErrWrongCredentials
|
|
}
|
|
|
|
c.debugLogger.Printf("Requesting session at ticket URL %s", ticketURL)
|
|
|
|
// Use ticket to request session.
|
|
req, _ = c.newRequest("GET", ticketURL, nil)
|
|
c.dump(req)
|
|
resp, err = c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.dump(resp)
|
|
resp.Body.Close()
|
|
|
|
// Look for the needed sessionid cookie.
|
|
for _, cookie := range resp.Cookies() {
|
|
if cookie.Name == cflbCookieName {
|
|
c.debugLogger.Printf("Found load balancer cookie with value %s", cookie.Value)
|
|
|
|
c.SetOptions(LoadBalancerID(cookie.Value))
|
|
}
|
|
|
|
if cookie.Name == sessionCookieName {
|
|
c.debugLogger.Printf("Found session cookie with value %s", cookie.Value)
|
|
|
|
c.SetOptions(SessionID(cookie.Value))
|
|
}
|
|
}
|
|
|
|
if c.SessionID == "" {
|
|
c.debugLogger.Printf("No sessionid found")
|
|
|
|
return ErrWrongCredentials
|
|
}
|
|
|
|
// The session id will not be valid until we redeem the sessions by
|
|
// following the redirect.
|
|
location := resp.Header.Get("Location")
|
|
c.debugLogger.Printf("Redeeming session id at %s", location)
|
|
|
|
req, _ = c.newRequest("GET", location, nil)
|
|
c.dump(req)
|
|
resp, err = c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.dump(resp)
|
|
|
|
c.Profile, err = extractSocialProfile(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp.Body.Close()
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractSocialProfile will try to extract the social profile from the HTML.
|
|
// This is very fragile.
|
|
func extractSocialProfile(body io.Reader) (*SocialProfile, error) {
|
|
scanner := bufio.NewScanner(body)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.Contains(line, "VIEWER_SOCIAL_PROFILE") {
|
|
line = strings.TrimSpace(line)
|
|
line = strings.Replace(line, "\\", "", -1)
|
|
line = strings.TrimPrefix(line, "window.VIEWER_SOCIAL_PROFILE = ")
|
|
line = strings.TrimSuffix(line, ";")
|
|
|
|
profile := new(SocialProfile)
|
|
|
|
err := json.Unmarshal([]byte(line), profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return profile, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("social profile not found in HTML")
|
|
}
|
|
|
|
// extractCSRFToken will try to extract the CSRF token from the signin form.
|
|
// This is very fragile. Maybe we should replace this madness by a real HTML
|
|
// parser some day.
|
|
func extractCSRFToken(body io.Reader) (string, error) {
|
|
scanner := bufio.NewScanner(body)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.Contains(line, "name=\"_csrf\"") {
|
|
line = strings.TrimSpace(line)
|
|
line = strings.TrimPrefix(line, `<input type="hidden" name="_csrf" value="`)
|
|
line = strings.TrimSuffix(line, `" />`)
|
|
|
|
return line, nil
|
|
}
|
|
}
|
|
|
|
return "", errors.New("CSRF token not found")
|
|
}
|
|
|
|
// Signout will end the session with Garmin. If you use this for regular
|
|
// automated tasks, it would be nice to signout each time to avoid filling
|
|
// Garmin's session tables with a lot of short-lived sessions.
|
|
func (c *Client) Signout() error {
|
|
if !c.authenticated() {
|
|
return ErrNotAuthenticated
|
|
}
|
|
|
|
req, err := c.newRequest("GET", "https://connect.garmin.com/modern/auth/logout", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.SessionID == "" {
|
|
return nil
|
|
}
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
c.SetOptions(SessionID(""))
|
|
c.SetOptions(LoadBalancerID(""))
|
|
|
|
return nil
|
|
}
|