// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. package cmsapi import ( "bytes" "compress/gzip" "context" _ "embed" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/la5nta/pat/internal/buildinfo" ) const ( RootURL = "https://api.winlink.org" PathVersionAdd = "/version/add" PathGatewayStatus = "/gateway/status.json" PathAccountExists = "/account/exists" PathPasswordValidate = "/account/password/validate" PathAccountAdd = "/account/add" // AccessKey issued December 2017 by the WDT for use with Pat AccessKey = "1880278F11684B358F36845615BD039A" ) type VersionAdd struct { Callsign string `json:"callsign"` Program string `json:"program"` Version string `json:"version"` Comments string `json:"comments,omitempty"` } func (v VersionAdd) Post() error { req := newJSONRequest("POST", PathVersionAdd, nil, bodyJSON(v)) var resp struct{ ResponseStatus responseStatus } if err := doJSON(req, &resp); err != nil { return err } return resp.ResponseStatus.errorOrNil() } func AccountExists(ctx context.Context, callsign string) (bool, error) { params := url.Values{"callsign": []string{callsign}} var resp struct { CallsignExists bool ResponseStatus responseStatus } if err := getJSON(ctx, PathAccountExists, params, &resp); err != nil { return false, err } return resp.CallsignExists, resp.ResponseStatus.errorOrNil() } type PasswordValidateRequest struct { Callsign string `json:"Callsign"` Password string `json:"Password"` } type PasswordValidateResponse struct { IsValid bool `json:"IsValid"` ResponseStatus responseStatus `json:"ResponseStatus"` } func ValidatePassword(ctx context.Context, callsign, password string) (bool, error) { req := PasswordValidateRequest{ Callsign: callsign, Password: password, } httpReq := newJSONRequest("POST", PathPasswordValidate, nil, bodyJSON(req)) httpReq = httpReq.WithContext(ctx) var resp PasswordValidateResponse if err := doJSON(httpReq, &resp); err != nil { return false, err } return resp.IsValid, resp.ResponseStatus.errorOrNil() } type AccountAddRequest struct { Callsign string `json:"Callsign"` Password string `json:"Password"` RecoveryEmail string `json:"RecoveryEmail,omitempty"` } type AccountAddResponse struct { ResponseStatus responseStatus `json:"ResponseStatus"` } func AccountAdd(ctx context.Context, callsign, password, recoveryEmail string) error { if t, _ := strconv.ParseBool(os.Getenv("PAT_CMSAPI_MOCK_ACCOUNT_ADD")); t { return nil } req := AccountAddRequest{ Callsign: callsign, Password: password, RecoveryEmail: recoveryEmail, } httpReq := newJSONRequest("POST", PathAccountAdd, nil, bodyJSON(req)) httpReq = httpReq.WithContext(ctx) var resp AccountAddResponse if err := doJSON(httpReq, &resp); err != nil { return err } return resp.ResponseStatus.errorOrNil() } type GatewayStatus struct { ServerName string `json:"ServerName"` ErrorCode int `json:"ErrorCode"` Gateways []Gateway `json:"Gateways"` } type Gateway struct { Callsign string BaseCallsign string RequestedMode string Comments string LastStatus RFC1123Time Latitude float64 Longitude float64 Channels []GatewayChannel `json:"GatewayChannels"` } type GatewayChannel struct { OperatingHours string SupportedModes string Frequency float64 ServiceCode string Baud string RadioRange string Mode int Gridsquare string Antenna string } type RFC1123Time struct{ time.Time } // GetGatewayStatus fetches the gateway status list returned by GatewayStatusUrl // // mode can be any of [packet, pactor, robustpacket, allhf or anyall]. Empty is AnyAll. // historyHours is the number of hours of history to include (maximum: 48). If < 1, then API default is used. // serviceCodes defaults to "PUBLIC". func GetGatewayStatus(ctx context.Context, mode string, historyHours int, serviceCodes ...string) (io.ReadCloser, error) { switch { case mode == "": mode = "AnyAll" case historyHours > 48: historyHours = 48 case len(serviceCodes) == 0: serviceCodes = []string{"PUBLIC"} } params := url.Values{"Mode": {mode}} params.Set("key", AccessKey) if historyHours >= 0 { params.Add("HistoryHours", fmt.Sprintf("%d", historyHours)) } for _, str := range serviceCodes { params.Add("ServiceCodes", str) } req, err := http.NewRequestWithContext(ctx, "POST", RootURL+PathGatewayStatus, strings.NewReader(params.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", buildinfo.UserAgent()) resp, err := http.DefaultClient.Do(req) switch { case err != nil: return nil, err case resp.StatusCode != http.StatusOK: return nil, fmt.Errorf("unexpected http status '%v'", resp.Status) } return resp.Body, err } //go:embed gateway_status.json.gz var embeddedGatewayStatus []byte func getGatewayStatusEmbedded() (io.ReadCloser, error) { return gzip.NewReader(bytes.NewReader(embeddedGatewayStatus)) } // GetGatewayStatusCached fetches the gateway status list, either from a cache file or by downloading it. // // If error occurs while downloading, it will fall back to the embedded gateway status information. func GetGatewayStatusCached(ctx context.Context, cacheFile string, forceDownload bool, serviceCodes ...string) (io.ReadCloser, error) { if !forceDownload { file, err := os.Open(cacheFile) if err == nil { return file, nil } } log.Println("Downloading latest gateway status information...") fresh, err := GetGatewayStatus(ctx, "", 48, serviceCodes...) if !forceDownload && err != nil { // If user didn't explicitly request a forced download, fail gracefully with the embedded dataset. log.Printf("Download failed: %v", err) log.Println("Loading embedded gateway status information") fresh, err = getGatewayStatusEmbedded() } if err != nil { return nil, err } file, err := os.Create(cacheFile) if err != nil { return nil, err } _, err = io.Copy(file, fresh) file.Seek(0, 0) return file, err } func (t *RFC1123Time) UnmarshalJSON(b []byte) (err error) { var str string if err = json.Unmarshal(b, &str); err != nil { return err } t.Time, err = time.Parse(time.RFC1123, str) return err }