github.com/meulengracht/snapd@v0.0.0-20210719210640-8bde69bcc84e/store/store.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2014-2021 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  // Package store has support to use the Ubuntu Store for querying and downloading of snaps, and the related services.
    21  package store
    22  
    23  import (
    24  	"bytes"
    25  	"context"
    26  	"encoding/json"
    27  	"errors"
    28  	"fmt"
    29  	"io"
    30  	"net/http"
    31  	"net/url"
    32  	"os"
    33  	"os/exec"
    34  	"path"
    35  	"strconv"
    36  	"strings"
    37  	"sync"
    38  	"time"
    39  
    40  	"gopkg.in/retry.v1"
    41  
    42  	"github.com/snapcore/snapd/arch"
    43  	"github.com/snapcore/snapd/client"
    44  	"github.com/snapcore/snapd/dirs"
    45  	"github.com/snapcore/snapd/httputil"
    46  	"github.com/snapcore/snapd/jsonutil"
    47  	"github.com/snapcore/snapd/logger"
    48  	"github.com/snapcore/snapd/osutil"
    49  	"github.com/snapcore/snapd/overlord/auth"
    50  	"github.com/snapcore/snapd/release"
    51  	"github.com/snapcore/snapd/snap"
    52  	"github.com/snapcore/snapd/snap/channel"
    53  	"github.com/snapcore/snapd/snap/naming"
    54  	"github.com/snapcore/snapd/snapdenv"
    55  	"github.com/snapcore/snapd/strutil"
    56  )
    57  
    58  // TODO: better/shorter names are probably in order once fewer legacy places are using this
    59  
    60  const (
    61  	// halJsonContentType is the default accept value for store requests
    62  	halJsonContentType = "application/hal+json"
    63  	// jsonContentType is for store enpoints that don't support HAL
    64  	jsonContentType = "application/json"
    65  	// UbuntuCoreWireProtocol is the protocol level we support when
    66  	// communicating with the store. History:
    67  	//  - "1": client supports squashfs snaps
    68  	UbuntuCoreWireProtocol = "1"
    69  )
    70  
    71  // the LimitTime should be slightly more than 3 times of our http.Client
    72  // Timeout value
    73  var defaultRetryStrategy = retry.LimitCount(6, retry.LimitTime(38*time.Second,
    74  	retry.Exponential{
    75  		Initial: 500 * time.Millisecond,
    76  		Factor:  2.5,
    77  	},
    78  ))
    79  
    80  var connCheckStrategy = retry.LimitCount(3, retry.LimitTime(38*time.Second,
    81  	retry.Exponential{
    82  		Initial: 900 * time.Millisecond,
    83  		Factor:  1.3,
    84  	},
    85  ))
    86  
    87  // Config represents the configuration to access the snap store
    88  type Config struct {
    89  	// Store API base URLs. The assertions url is only separate because it can
    90  	// be overridden by its own env var.
    91  	StoreBaseURL      *url.URL
    92  	AssertionsBaseURL *url.URL
    93  
    94  	// StoreID is the store id used if we can't get one through the DeviceAndAuthContext.
    95  	StoreID string
    96  
    97  	Architecture string
    98  	Series       string
    99  
   100  	DetailFields []string
   101  	InfoFields   []string
   102  	// search v2 fields
   103  	FindFields  []string
   104  	DeltaFormat string
   105  
   106  	// CacheDownloads is the number of downloads that should be cached
   107  	CacheDownloads int
   108  
   109  	// Proxy returns the HTTP proxy to use when talking to the store
   110  	Proxy func(*http.Request) (*url.URL, error)
   111  }
   112  
   113  // setBaseURL updates the store API's base URL in the Config. Must not be used
   114  // to change active config.
   115  func (cfg *Config) setBaseURL(u *url.URL) error {
   116  	storeBaseURI, err := storeURL(u)
   117  	if err != nil {
   118  		return err
   119  	}
   120  
   121  	assertsBaseURI, err := assertsURL()
   122  	if err != nil {
   123  		return err
   124  	}
   125  
   126  	cfg.StoreBaseURL = storeBaseURI
   127  	cfg.AssertionsBaseURL = assertsBaseURI
   128  
   129  	return nil
   130  }
   131  
   132  // Store represents the ubuntu snap store
   133  type Store struct {
   134  	cfg *Config
   135  
   136  	architecture string
   137  	series       string
   138  
   139  	noCDN bool
   140  
   141  	fallbackStoreID string
   142  
   143  	detailFields []string
   144  	infoFields   []string
   145  	findFields   []string
   146  	deltaFormat  string
   147  	// reused http client
   148  	client *http.Client
   149  
   150  	dauthCtx  DeviceAndAuthContext
   151  	sessionMu sync.Mutex
   152  
   153  	mu                sync.Mutex
   154  	suggestedCurrency string
   155  
   156  	cacher downloadCache
   157  
   158  	proxy              func(*http.Request) (*url.URL, error)
   159  	proxyConnectHeader http.Header
   160  
   161  	userAgent string
   162  
   163  	xdeltaCheckLock sync.Mutex
   164  	// whether we should use deltas or not
   165  	shouldUseDeltas *bool
   166  	// which xdelta3 we picked when we checked the deltas
   167  	xdelta3CmdFunc func(args ...string) *exec.Cmd
   168  }
   169  
   170  var ErrTooManyRequests = errors.New("too many requests")
   171  
   172  // UnexpectedHTTPStatusError represents an error where the store
   173  // returned an unexpected HTTP status code, i.e. a status code that
   174  // doesn't represent success nor an expected error condition with
   175  // known handling (e.g. a 404 when instead presence is always
   176  // expected).
   177  type UnexpectedHTTPStatusError struct {
   178  	OpSummary  string
   179  	StatusCode int
   180  	Method     string
   181  	URL        *url.URL
   182  	OopsID     string
   183  }
   184  
   185  func (e *UnexpectedHTTPStatusError) Error() string {
   186  	tpl := "cannot %s: got unexpected HTTP status code %d via %s to %q"
   187  	if e.OopsID != "" {
   188  		tpl += " [%s]"
   189  		return fmt.Sprintf(tpl, e.OpSummary, e.StatusCode, e.Method, e.URL, e.OopsID)
   190  	}
   191  	return fmt.Sprintf(tpl, e.OpSummary, e.StatusCode, e.Method, e.URL)
   192  }
   193  
   194  func respToError(resp *http.Response, opSummary string) error {
   195  	if resp.StatusCode == 429 {
   196  		return ErrTooManyRequests
   197  	}
   198  	return &UnexpectedHTTPStatusError{
   199  		OpSummary:  opSummary,
   200  		StatusCode: resp.StatusCode,
   201  		Method:     resp.Request.Method,
   202  		URL:        resp.Request.URL,
   203  		OopsID:     resp.Header.Get("X-Oops-Id"),
   204  	}
   205  }
   206  
   207  // endpointURL clones a base URL and updates it with optional path and query.
   208  func endpointURL(base *url.URL, path string, query url.Values) *url.URL {
   209  	u := *base
   210  	if path != "" {
   211  		u.Path = strings.TrimSuffix(u.Path, "/") + "/" + strings.TrimPrefix(path, "/")
   212  		u.RawQuery = ""
   213  	}
   214  	if len(query) != 0 {
   215  		u.RawQuery = query.Encode()
   216  	}
   217  	return &u
   218  }
   219  
   220  // apiURL returns the system default base API URL.
   221  func apiURL() *url.URL {
   222  	s := "https://api.snapcraft.io/"
   223  	if snapdenv.UseStagingStore() {
   224  		s = "https://api.staging.snapcraft.io/"
   225  	}
   226  	u, _ := url.Parse(s)
   227  	return u
   228  }
   229  
   230  // storeURL returns the base store URL, derived from either the given API URL
   231  // or an env var override.
   232  func storeURL(api *url.URL) (*url.URL, error) {
   233  	var override string
   234  	var overrideName string
   235  	// XXX: time to drop FORCE_CPI support
   236  	// XXX: Deprecated but present for backward-compatibility: this used
   237  	// to be "Click Package Index".  Remove this once people have got
   238  	// used to SNAPPY_FORCE_API_URL instead.
   239  	if s := os.Getenv("SNAPPY_FORCE_CPI_URL"); s != "" && strings.HasSuffix(s, "api/v1/") {
   240  		overrideName = "SNAPPY_FORCE_CPI_URL"
   241  		override = strings.TrimSuffix(s, "api/v1/")
   242  	} else if s := os.Getenv("SNAPPY_FORCE_API_URL"); s != "" {
   243  		overrideName = "SNAPPY_FORCE_API_URL"
   244  		override = s
   245  	}
   246  	if override != "" {
   247  		u, err := url.Parse(override)
   248  		if err != nil {
   249  			return nil, fmt.Errorf("invalid %s: %s", overrideName, err)
   250  		}
   251  		return u, nil
   252  	}
   253  	return api, nil
   254  }
   255  
   256  func assertsURL() (*url.URL, error) {
   257  	if s := os.Getenv("SNAPPY_FORCE_SAS_URL"); s != "" {
   258  		u, err := url.Parse(s)
   259  		if err != nil {
   260  			return nil, fmt.Errorf("invalid SNAPPY_FORCE_SAS_URL: %s", err)
   261  		}
   262  		return u, nil
   263  	}
   264  
   265  	// nil means fallback to store base url
   266  	return nil, nil
   267  }
   268  
   269  func authLocation() string {
   270  	if snapdenv.UseStagingStore() {
   271  		return "login.staging.ubuntu.com"
   272  	}
   273  	return "login.ubuntu.com"
   274  }
   275  
   276  func authURL() string {
   277  	if u := os.Getenv("SNAPPY_FORCE_SSO_URL"); u != "" {
   278  		return u
   279  	}
   280  	return "https://" + authLocation() + "/api/v2"
   281  }
   282  
   283  var defaultStoreDeveloperURL = "https://dashboard.snapcraft.io/"
   284  
   285  func storeDeveloperURL() string {
   286  	if snapdenv.UseStagingStore() {
   287  		return "https://dashboard.staging.snapcraft.io/"
   288  	}
   289  	return defaultStoreDeveloperURL
   290  }
   291  
   292  var defaultConfig = Config{}
   293  
   294  // DefaultConfig returns a copy of the default configuration ready to be adapted.
   295  func DefaultConfig() *Config {
   296  	cfg := defaultConfig
   297  	return &cfg
   298  }
   299  
   300  func init() {
   301  	storeBaseURI, err := storeURL(apiURL())
   302  	if err != nil {
   303  		panic(err)
   304  	}
   305  	if storeBaseURI.RawQuery != "" {
   306  		panic("store API URL may not contain query string")
   307  	}
   308  	err = defaultConfig.setBaseURL(storeBaseURI)
   309  	if err != nil {
   310  		panic(err)
   311  	}
   312  	defaultConfig.DetailFields = jsonutil.StructFields((*snapDetails)(nil), "snap_yaml_raw")
   313  	defaultConfig.InfoFields = jsonutil.StructFields((*storeSnap)(nil), "snap-yaml")
   314  	defaultConfig.FindFields = append(jsonutil.StructFields((*storeSnap)(nil),
   315  		"architectures", "created-at", "epoch", "name", "snap-id", "snap-yaml"),
   316  		"channel")
   317  }
   318  
   319  type searchV2Results struct {
   320  	Results   []*storeSearchResult `json:"results"`
   321  	ErrorList []struct {
   322  		Code    string `json:"code"`
   323  		Message string `json:"message"`
   324  	} `json:"error-list"`
   325  }
   326  
   327  type searchResults struct {
   328  	Payload struct {
   329  		Packages []*snapDetails `json:"clickindex:package"`
   330  	} `json:"_embedded"`
   331  }
   332  
   333  type sectionResults struct {
   334  	Payload struct {
   335  		Sections []struct{ Name string } `json:"clickindex:sections"`
   336  	} `json:"_embedded"`
   337  }
   338  
   339  // The default delta format if not configured.
   340  var defaultSupportedDeltaFormat = "xdelta3"
   341  
   342  // New creates a new Store with the given access configuration and for given the store id.
   343  func New(cfg *Config, dauthCtx DeviceAndAuthContext) *Store {
   344  	if cfg == nil {
   345  		cfg = &defaultConfig
   346  	}
   347  
   348  	detailFields := cfg.DetailFields
   349  	if detailFields == nil {
   350  		detailFields = defaultConfig.DetailFields
   351  	}
   352  
   353  	infoFields := cfg.InfoFields
   354  	if infoFields == nil {
   355  		infoFields = defaultConfig.InfoFields
   356  	}
   357  
   358  	findFields := cfg.FindFields
   359  	if findFields == nil {
   360  		findFields = defaultConfig.FindFields
   361  	}
   362  
   363  	architecture := cfg.Architecture
   364  	if cfg.Architecture == "" {
   365  		architecture = arch.DpkgArchitecture()
   366  	}
   367  
   368  	series := cfg.Series
   369  	if cfg.Series == "" {
   370  		series = release.Series
   371  	}
   372  
   373  	deltaFormat := cfg.DeltaFormat
   374  	if deltaFormat == "" {
   375  		deltaFormat = defaultSupportedDeltaFormat
   376  	}
   377  
   378  	userAgent := snapdenv.UserAgent()
   379  	proxyConnectHeader := http.Header{"User-Agent": []string{userAgent}}
   380  
   381  	store := &Store{
   382  		cfg:                cfg,
   383  		series:             series,
   384  		architecture:       architecture,
   385  		noCDN:              osutil.GetenvBool("SNAPPY_STORE_NO_CDN"),
   386  		fallbackStoreID:    cfg.StoreID,
   387  		detailFields:       detailFields,
   388  		infoFields:         infoFields,
   389  		findFields:         findFields,
   390  		dauthCtx:           dauthCtx,
   391  		deltaFormat:        deltaFormat,
   392  		proxy:              cfg.Proxy,
   393  		proxyConnectHeader: proxyConnectHeader,
   394  		userAgent:          userAgent,
   395  	}
   396  	store.client = store.newHTTPClient(&httputil.ClientOptions{
   397  		Timeout:    10 * time.Second,
   398  		MayLogBody: true,
   399  	})
   400  	store.SetCacheDownloads(cfg.CacheDownloads)
   401  
   402  	return store
   403  }
   404  
   405  // API endpoint paths
   406  const (
   407  	// see https://dashboard.snapcraft.io/docs/
   408  	// XXX: Repeating "api/" here is cumbersome, but the next generation
   409  	// of store APIs will probably drop that prefix (since it now
   410  	// duplicates the hostname), and we may want to switch to v2 APIs
   411  	// one at a time; so it's better to consider that as part of
   412  	// individual endpoint paths.
   413  	searchEndpPath      = "api/v1/snaps/search"
   414  	ordersEndpPath      = "api/v1/snaps/purchases/orders"
   415  	buyEndpPath         = "api/v1/snaps/purchases/buy"
   416  	customersMeEndpPath = "api/v1/snaps/purchases/customers/me"
   417  	sectionsEndpPath    = "api/v1/snaps/sections"
   418  	commandsEndpPath    = "api/v1/snaps/names"
   419  	// v2
   420  	snapActionEndpPath = "v2/snaps/refresh"
   421  	snapInfoEndpPath   = "v2/snaps/info"
   422  	cohortsEndpPath    = "v2/cohorts"
   423  	findEndpPath       = "v2/snaps/find"
   424  
   425  	deviceNonceEndpPath   = "api/v1/snaps/auth/nonces"
   426  	deviceSessionEndpPath = "api/v1/snaps/auth/sessions"
   427  
   428  	assertionsPath = "v2/assertions"
   429  )
   430  
   431  func (s *Store) newHTTPClient(opts *httputil.ClientOptions) *http.Client {
   432  	if opts == nil {
   433  		opts = &httputil.ClientOptions{}
   434  	}
   435  	opts.Proxy = s.cfg.Proxy
   436  	opts.ProxyConnectHeader = s.proxyConnectHeader
   437  	opts.ExtraSSLCerts = &httputil.ExtraSSLCertsFromDir{
   438  		Dir: dirs.SnapdStoreSSLCertsDir,
   439  	}
   440  	return httputil.NewHTTPClient(opts)
   441  }
   442  
   443  func (s *Store) defaultSnapQuery() url.Values {
   444  	q := url.Values{}
   445  	if len(s.detailFields) != 0 {
   446  		q.Set("fields", strings.Join(s.detailFields, ","))
   447  	}
   448  	return q
   449  }
   450  
   451  func (s *Store) baseURL(defaultURL *url.URL) *url.URL {
   452  	u := defaultURL
   453  	if s.dauthCtx != nil {
   454  		var err error
   455  		_, u, err = s.dauthCtx.ProxyStoreParams(defaultURL)
   456  		if err != nil {
   457  			logger.Debugf("cannot get proxy store parameters from state: %v", err)
   458  		}
   459  	}
   460  	if u != nil {
   461  		return u
   462  	}
   463  	return defaultURL
   464  }
   465  
   466  func (s *Store) endpointURL(p string, query url.Values) *url.URL {
   467  	return endpointURL(s.baseURL(s.cfg.StoreBaseURL), p, query)
   468  }
   469  
   470  // LoginUser logs user in the store and returns the authentication macaroons.
   471  func (s *Store) LoginUser(username, password, otp string) (string, string, error) {
   472  	macaroon, err := requestStoreMacaroon(s.client)
   473  	if err != nil {
   474  		return "", "", err
   475  	}
   476  	deserializedMacaroon, err := auth.MacaroonDeserialize(macaroon)
   477  	if err != nil {
   478  		return "", "", err
   479  	}
   480  
   481  	// get SSO 3rd party caveat, and request discharge
   482  	loginCaveat, err := loginCaveatID(deserializedMacaroon)
   483  	if err != nil {
   484  		return "", "", err
   485  	}
   486  
   487  	discharge, err := dischargeAuthCaveat(s.client, loginCaveat, username, password, otp)
   488  	if err != nil {
   489  		return "", "", err
   490  	}
   491  
   492  	return macaroon, discharge, nil
   493  }
   494  
   495  // authAvailable returns true if there is a user and/or device session setup
   496  func (s *Store) authAvailable(user *auth.UserState) (bool, error) {
   497  	if user.HasStoreAuth() {
   498  		return true, nil
   499  	} else {
   500  		var device *auth.DeviceState
   501  		var err error
   502  		if s.dauthCtx != nil {
   503  			device, err = s.dauthCtx.Device()
   504  			if err != nil {
   505  				return false, err
   506  			}
   507  		}
   508  		return device != nil && device.SessionMacaroon != "", nil
   509  	}
   510  }
   511  
   512  // authenticateUser will add the store expected Macaroon Authorization header for user
   513  func authenticateUser(r *http.Request, user *auth.UserState) {
   514  	var buf bytes.Buffer
   515  	fmt.Fprintf(&buf, `Macaroon root="%s"`, user.StoreMacaroon)
   516  
   517  	// deserialize root macaroon (we need its signature to do the discharge binding)
   518  	root, err := auth.MacaroonDeserialize(user.StoreMacaroon)
   519  	if err != nil {
   520  		logger.Debugf("cannot deserialize root macaroon: %v", err)
   521  		return
   522  	}
   523  
   524  	for _, d := range user.StoreDischarges {
   525  		// prepare discharge for request
   526  		discharge, err := auth.MacaroonDeserialize(d)
   527  		if err != nil {
   528  			logger.Debugf("cannot deserialize discharge macaroon: %v", err)
   529  			return
   530  		}
   531  		discharge.Bind(root.Signature())
   532  
   533  		serializedDischarge, err := auth.MacaroonSerialize(discharge)
   534  		if err != nil {
   535  			logger.Debugf("cannot re-serialize discharge macaroon: %v", err)
   536  			return
   537  		}
   538  		fmt.Fprintf(&buf, `, discharge="%s"`, serializedDischarge)
   539  	}
   540  	r.Header.Set("Authorization", buf.String())
   541  }
   542  
   543  // refreshDischarges will request refreshed discharge macaroons for the user
   544  func refreshDischarges(httpClient *http.Client, user *auth.UserState) ([]string, error) {
   545  	newDischarges := make([]string, len(user.StoreDischarges))
   546  	for i, d := range user.StoreDischarges {
   547  		discharge, err := auth.MacaroonDeserialize(d)
   548  		if err != nil {
   549  			return nil, err
   550  		}
   551  		if discharge.Location() != UbuntuoneLocation {
   552  			newDischarges[i] = d
   553  			continue
   554  		}
   555  
   556  		refreshedDischarge, err := refreshDischargeMacaroon(httpClient, d)
   557  		if err != nil {
   558  			return nil, err
   559  		}
   560  		newDischarges[i] = refreshedDischarge
   561  	}
   562  	return newDischarges, nil
   563  }
   564  
   565  // refreshUser will refresh user discharge macaroon and update state
   566  func (s *Store) refreshUser(user *auth.UserState) error {
   567  	if s.dauthCtx == nil {
   568  		return fmt.Errorf("user credentials need to be refreshed but update in place only supported in snapd")
   569  	}
   570  	newDischarges, err := refreshDischarges(s.client, user)
   571  	if err != nil {
   572  		return err
   573  	}
   574  
   575  	curUser, err := s.dauthCtx.UpdateUserAuth(user, newDischarges)
   576  	if err != nil {
   577  		return err
   578  	}
   579  	// update in place
   580  	*user = *curUser
   581  
   582  	return nil
   583  }
   584  
   585  // refreshDeviceSession will set or refresh the device session in the state
   586  func (s *Store) refreshDeviceSession(device *auth.DeviceState) error {
   587  	if s.dauthCtx == nil {
   588  		return fmt.Errorf("internal error: no device and auth context")
   589  	}
   590  
   591  	s.sessionMu.Lock()
   592  	defer s.sessionMu.Unlock()
   593  	// check that no other goroutine has already got a new session etc...
   594  	device1, err := s.dauthCtx.Device()
   595  	if err != nil {
   596  		return err
   597  	}
   598  	// We can replace device with "device1" here because Device
   599  	// and UpdateDeviceAuth (and the underlying SetDevice)
   600  	// require/use the global state lock, so the reading/setting
   601  	// values have a total order, and device1 cannot come before
   602  	// device in that order. See also:
   603  	// https://github.com/snapcore/snapd/pull/6716#discussion_r277025834
   604  	if *device1 != *device {
   605  		// nothing to do
   606  		*device = *device1
   607  		return nil
   608  	}
   609  
   610  	nonce, err := requestStoreDeviceNonce(s.client, s.endpointURL(deviceNonceEndpPath, nil).String())
   611  	if err != nil {
   612  		return err
   613  	}
   614  
   615  	devSessReqParams, err := s.dauthCtx.DeviceSessionRequestParams(nonce)
   616  	if err != nil {
   617  		return err
   618  	}
   619  
   620  	session, err := requestDeviceSession(s.client, s.endpointURL(deviceSessionEndpPath, nil).String(), devSessReqParams, device.SessionMacaroon)
   621  	if err != nil {
   622  		return err
   623  	}
   624  
   625  	curDevice, err := s.dauthCtx.UpdateDeviceAuth(device, session)
   626  	if err != nil {
   627  		return err
   628  	}
   629  	// update in place
   630  	*device = *curDevice
   631  	return nil
   632  }
   633  
   634  // EnsureDeviceSession makes sure the store has a device session available.
   635  // Expects the store to have an AuthContext.
   636  func (s *Store) EnsureDeviceSession() (*auth.DeviceState, error) {
   637  	if s.dauthCtx == nil {
   638  		return nil, fmt.Errorf("internal error: no authContext")
   639  	}
   640  
   641  	device, err := s.dauthCtx.Device()
   642  	if err != nil {
   643  		return nil, err
   644  	}
   645  
   646  	if device.SessionMacaroon != "" {
   647  		return device, nil
   648  	}
   649  	if device.Serial == "" {
   650  		return nil, ErrNoSerial
   651  	}
   652  	// we don't have a session yet but have a serial, try
   653  	// to get a session
   654  	err = s.refreshDeviceSession(device)
   655  	if err != nil {
   656  		return nil, err
   657  	}
   658  	return device, err
   659  }
   660  
   661  // authenticateDevice will add the store expected Macaroon X-Device-Authorization header for device
   662  func authenticateDevice(r *http.Request, device *auth.DeviceState, apiLevel apiLevel) {
   663  	if device != nil && device.SessionMacaroon != "" {
   664  		r.Header.Set(hdrSnapDeviceAuthorization[apiLevel], fmt.Sprintf(`Macaroon root="%s"`, device.SessionMacaroon))
   665  	}
   666  }
   667  
   668  func (s *Store) setStoreID(r *http.Request, apiLevel apiLevel) (customStore bool) {
   669  	storeID := s.fallbackStoreID
   670  	if s.dauthCtx != nil {
   671  		cand, err := s.dauthCtx.StoreID(storeID)
   672  		if err != nil {
   673  			logger.Debugf("cannot get store ID from state: %v", err)
   674  		} else {
   675  			storeID = cand
   676  		}
   677  	}
   678  	if storeID != "" {
   679  		r.Header.Set(hdrSnapDeviceStore[apiLevel], storeID)
   680  		return true
   681  	}
   682  	return false
   683  }
   684  
   685  type apiLevel int
   686  
   687  const (
   688  	apiV1Endps apiLevel = 0 // api/v1 endpoints
   689  	apiV2Endps apiLevel = 1 // v2 endpoints
   690  )
   691  
   692  var (
   693  	hdrSnapDeviceAuthorization = []string{"X-Device-Authorization", "Snap-Device-Authorization"}
   694  	hdrSnapDeviceStore         = []string{"X-Ubuntu-Store", "Snap-Device-Store"}
   695  	hdrSnapDeviceSeries        = []string{"X-Ubuntu-Series", "Snap-Device-Series"}
   696  	hdrSnapDeviceArchitecture  = []string{"X-Ubuntu-Architecture", "Snap-Device-Architecture"}
   697  	hdrSnapClassic             = []string{"X-Ubuntu-Classic", "Snap-Classic"}
   698  )
   699  
   700  type deviceAuthNeed int
   701  
   702  const (
   703  	deviceAuthPreferred deviceAuthNeed = iota
   704  	deviceAuthCustomStoreOnly
   705  )
   706  
   707  // requestOptions specifies parameters for store requests.
   708  type requestOptions struct {
   709  	Method       string
   710  	URL          *url.URL
   711  	Accept       string
   712  	ContentType  string
   713  	APILevel     apiLevel
   714  	ExtraHeaders map[string]string
   715  	Data         []byte
   716  
   717  	// DeviceAuthNeed indicates the level of need to supply device
   718  	// authorization for this request, can be:
   719  	//  - deviceAuthPreferred: should be provided if available
   720  	//  - deviceAuthCustomStoreOnly: should be provided only in case
   721  	//    of a custom store
   722  	DeviceAuthNeed deviceAuthNeed
   723  }
   724  
   725  func (r *requestOptions) addHeader(k, v string) {
   726  	if r.ExtraHeaders == nil {
   727  		r.ExtraHeaders = make(map[string]string)
   728  	}
   729  	r.ExtraHeaders[k] = v
   730  }
   731  
   732  func cancelled(ctx context.Context) bool {
   733  	select {
   734  	case <-ctx.Done():
   735  		return true
   736  	default:
   737  		return false
   738  	}
   739  }
   740  
   741  var expectedCatalogPreamble = []interface{}{
   742  	json.Delim('{'),
   743  	"_embedded",
   744  	json.Delim('{'),
   745  	"clickindex:package",
   746  	json.Delim('['),
   747  }
   748  
   749  type alias struct {
   750  	Name string `json:"name"`
   751  }
   752  
   753  type catalogItem struct {
   754  	Name    string   `json:"package_name"`
   755  	Version string   `json:"version"`
   756  	Summary string   `json:"summary"`
   757  	Aliases []alias  `json:"aliases"`
   758  	Apps    []string `json:"apps"`
   759  }
   760  
   761  type SnapAdder interface {
   762  	AddSnap(snapName, version, summary string, commands []string) error
   763  }
   764  
   765  func decodeCatalog(resp *http.Response, names io.Writer, db SnapAdder) error {
   766  	const what = "decode new commands catalog"
   767  	if resp.StatusCode != 200 {
   768  		return respToError(resp, what)
   769  	}
   770  	dec := json.NewDecoder(resp.Body)
   771  	for _, expectedToken := range expectedCatalogPreamble {
   772  		token, err := dec.Token()
   773  		if err != nil {
   774  			return err
   775  		}
   776  		if token != expectedToken {
   777  			return fmt.Errorf(what+": bad catalog preamble: expected %#v, got %#v", expectedToken, token)
   778  		}
   779  	}
   780  
   781  	for dec.More() {
   782  		var v catalogItem
   783  		if err := dec.Decode(&v); err != nil {
   784  			return fmt.Errorf(what+": %v", err)
   785  		}
   786  		if v.Name == "" {
   787  			continue
   788  		}
   789  		fmt.Fprintln(names, v.Name)
   790  		if len(v.Apps) == 0 {
   791  			continue
   792  		}
   793  
   794  		commands := make([]string, 0, len(v.Aliases)+len(v.Apps))
   795  
   796  		for _, alias := range v.Aliases {
   797  			commands = append(commands, alias.Name)
   798  		}
   799  		for _, app := range v.Apps {
   800  			commands = append(commands, snap.JoinSnapApp(v.Name, app))
   801  		}
   802  
   803  		if err := db.AddSnap(v.Name, v.Version, v.Summary, commands); err != nil {
   804  			return err
   805  		}
   806  	}
   807  
   808  	return nil
   809  }
   810  
   811  func decodeJSONBody(resp *http.Response, success interface{}, failure interface{}) error {
   812  	ok := (resp.StatusCode == 200 || resp.StatusCode == 201)
   813  	// always decode on success; decode failures only if body is not empty
   814  	if !ok && resp.ContentLength == 0 {
   815  		return nil
   816  	}
   817  	result := success
   818  	if !ok {
   819  		result = failure
   820  	}
   821  	if result != nil {
   822  		return json.NewDecoder(resp.Body).Decode(result)
   823  	}
   824  	return nil
   825  }
   826  
   827  // retryRequestDecodeJSON calls retryRequest and decodes the response into either success or failure.
   828  func (s *Store) retryRequestDecodeJSON(ctx context.Context, reqOptions *requestOptions, user *auth.UserState, success interface{}, failure interface{}) (resp *http.Response, err error) {
   829  	return httputil.RetryRequest(reqOptions.URL.String(), func() (*http.Response, error) {
   830  		return s.doRequest(ctx, s.client, reqOptions, user)
   831  	}, func(resp *http.Response) error {
   832  		return decodeJSONBody(resp, success, failure)
   833  	}, defaultRetryStrategy)
   834  }
   835  
   836  // doRequest does an authenticated request to the store handling a potential macaroon refresh required if needed
   837  func (s *Store) doRequest(ctx context.Context, client *http.Client, reqOptions *requestOptions, user *auth.UserState) (*http.Response, error) {
   838  	authRefreshes := 0
   839  	for {
   840  		req, err := s.newRequest(ctx, reqOptions, user)
   841  		if err != nil {
   842  			return nil, err
   843  		}
   844  		if ctx != nil {
   845  			req = req.WithContext(ctx)
   846  		}
   847  
   848  		resp, err := client.Do(req)
   849  		if err != nil {
   850  			return nil, err
   851  		}
   852  
   853  		wwwAuth := resp.Header.Get("WWW-Authenticate")
   854  		if resp.StatusCode == 401 && authRefreshes < 4 {
   855  			// 4 tries: 2 tries for each in case both user
   856  			// and device need refreshing
   857  			var refreshNeed authRefreshNeed
   858  			if user != nil && strings.Contains(wwwAuth, "needs_refresh=1") {
   859  				// refresh user
   860  				refreshNeed.user = true
   861  			}
   862  			if strings.Contains(wwwAuth, "refresh_device_session=1") {
   863  				// refresh device session
   864  				refreshNeed.device = true
   865  			}
   866  			if refreshNeed.needed() {
   867  				err := s.refreshAuth(user, refreshNeed)
   868  				if err != nil {
   869  					return nil, err
   870  				}
   871  				// close previous response and retry
   872  				resp.Body.Close()
   873  				authRefreshes++
   874  				continue
   875  			}
   876  		}
   877  
   878  		return resp, err
   879  	}
   880  }
   881  
   882  type authRefreshNeed struct {
   883  	device bool
   884  	user   bool
   885  }
   886  
   887  func (rn *authRefreshNeed) needed() bool {
   888  	return rn.device || rn.user
   889  }
   890  
   891  func (s *Store) refreshAuth(user *auth.UserState, need authRefreshNeed) error {
   892  	if need.user {
   893  		// refresh user
   894  		err := s.refreshUser(user)
   895  		if err != nil {
   896  			return err
   897  		}
   898  	}
   899  	if need.device {
   900  		// refresh device session
   901  		if s.dauthCtx == nil {
   902  			return fmt.Errorf("internal error: no device and auth context")
   903  		}
   904  		device, err := s.dauthCtx.Device()
   905  		if err != nil {
   906  			return err
   907  		}
   908  
   909  		err = s.refreshDeviceSession(device)
   910  		if err != nil {
   911  			return err
   912  		}
   913  	}
   914  	return nil
   915  }
   916  
   917  // build a new http.Request with headers for the store
   918  func (s *Store) newRequest(ctx context.Context, reqOptions *requestOptions, user *auth.UserState) (*http.Request, error) {
   919  	var body io.Reader
   920  	if reqOptions.Data != nil {
   921  		body = bytes.NewBuffer(reqOptions.Data)
   922  	}
   923  
   924  	req, err := http.NewRequest(reqOptions.Method, reqOptions.URL.String(), body)
   925  	if err != nil {
   926  		return nil, err
   927  	}
   928  
   929  	customStore := s.setStoreID(req, reqOptions.APILevel)
   930  
   931  	if s.dauthCtx != nil && (customStore || reqOptions.DeviceAuthNeed != deviceAuthCustomStoreOnly) {
   932  		device, err := s.EnsureDeviceSession()
   933  		if err != nil && err != ErrNoSerial {
   934  			return nil, err
   935  		}
   936  		if err == ErrNoSerial {
   937  			// missing serial assertion, log and continue without device authentication
   938  			logger.Debugf("cannot set device session: %v", err)
   939  		} else {
   940  			authenticateDevice(req, device, reqOptions.APILevel)
   941  		}
   942  	}
   943  
   944  	// only set user authentication if user logged in to the store
   945  	if user.HasStoreAuth() {
   946  		authenticateUser(req, user)
   947  	}
   948  
   949  	req.Header.Set("User-Agent", s.userAgent)
   950  	req.Header.Set("Accept", reqOptions.Accept)
   951  	req.Header.Set(hdrSnapDeviceArchitecture[reqOptions.APILevel], s.architecture)
   952  	req.Header.Set(hdrSnapDeviceSeries[reqOptions.APILevel], s.series)
   953  	req.Header.Set(hdrSnapClassic[reqOptions.APILevel], strconv.FormatBool(release.OnClassic))
   954  	req.Header.Set("Snap-Device-Capabilities", "default-tracks")
   955  	if cua := ClientUserAgent(ctx); cua != "" {
   956  		req.Header.Set("Snap-Client-User-Agent", cua)
   957  	}
   958  	if reqOptions.APILevel == apiV1Endps {
   959  		req.Header.Set("X-Ubuntu-Wire-Protocol", UbuntuCoreWireProtocol)
   960  	}
   961  
   962  	if reqOptions.ContentType != "" {
   963  		req.Header.Set("Content-Type", reqOptions.ContentType)
   964  	}
   965  
   966  	for header, value := range reqOptions.ExtraHeaders {
   967  		req.Header.Set(header, value)
   968  	}
   969  
   970  	return req, nil
   971  }
   972  
   973  func (s *Store) extractSuggestedCurrency(resp *http.Response) {
   974  	suggestedCurrency := resp.Header.Get("X-Suggested-Currency")
   975  
   976  	if suggestedCurrency != "" {
   977  		s.mu.Lock()
   978  		s.suggestedCurrency = suggestedCurrency
   979  		s.mu.Unlock()
   980  	}
   981  }
   982  
   983  // ordersResult encapsulates the order data sent to us from the software center agent.
   984  //
   985  // {
   986  //   "orders": [
   987  //     {
   988  //       "snap_id": "abcd1234efgh5678ijkl9012",
   989  //       "currency": "USD",
   990  //       "amount": "2.99",
   991  //       "state": "Complete",
   992  //       "refundable_until": null,
   993  //       "purchase_date": "2016-09-20T15:00:00+00:00"
   994  //     },
   995  //     {
   996  //       "snap_id": "abcd1234efgh5678ijkl9012",
   997  //       "currency": null,
   998  //       "amount": null,
   999  //       "state": "Complete",
  1000  //       "refundable_until": null,
  1001  //       "purchase_date": "2016-09-20T15:00:00+00:00"
  1002  //     }
  1003  //   ]
  1004  // }
  1005  type ordersResult struct {
  1006  	Orders []*order `json:"orders"`
  1007  }
  1008  
  1009  type order struct {
  1010  	SnapID          string `json:"snap_id"`
  1011  	Currency        string `json:"currency"`
  1012  	Amount          string `json:"amount"`
  1013  	State           string `json:"state"`
  1014  	RefundableUntil string `json:"refundable_until"`
  1015  	PurchaseDate    string `json:"purchase_date"`
  1016  }
  1017  
  1018  // decorateOrders sets the MustBuy property of each snap in the given list according to the user's known orders.
  1019  func (s *Store) decorateOrders(snaps []*snap.Info, user *auth.UserState) error {
  1020  	// Mark every non-free snap as must buy until we know better.
  1021  	hasPriced := false
  1022  	for _, info := range snaps {
  1023  		if info.Paid {
  1024  			info.MustBuy = true
  1025  			hasPriced = true
  1026  		}
  1027  	}
  1028  
  1029  	if user == nil {
  1030  		return nil
  1031  	}
  1032  
  1033  	if !hasPriced {
  1034  		return nil
  1035  	}
  1036  
  1037  	var err error
  1038  
  1039  	reqOptions := &requestOptions{
  1040  		Method: "GET",
  1041  		URL:    s.endpointURL(ordersEndpPath, nil),
  1042  		Accept: jsonContentType,
  1043  	}
  1044  	var result ordersResult
  1045  	resp, err := s.retryRequestDecodeJSON(context.TODO(), reqOptions, user, &result, nil)
  1046  	if err != nil {
  1047  		return err
  1048  	}
  1049  
  1050  	if resp.StatusCode == 401 {
  1051  		// TODO handle token expiry and refresh
  1052  		return ErrInvalidCredentials
  1053  	}
  1054  	if resp.StatusCode != 200 {
  1055  		return respToError(resp, "obtain known orders from store")
  1056  	}
  1057  
  1058  	// Make a map of the IDs of bought snaps
  1059  	bought := make(map[string]bool)
  1060  	for _, order := range result.Orders {
  1061  		bought[order.SnapID] = true
  1062  	}
  1063  
  1064  	for _, info := range snaps {
  1065  		info.MustBuy = mustBuy(info.Paid, bought[info.SnapID])
  1066  	}
  1067  
  1068  	return nil
  1069  }
  1070  
  1071  // mustBuy determines if a snap requires a payment, based on if it is non-free and if the user has already bought it
  1072  func mustBuy(paid bool, bought bool) bool {
  1073  	if !paid {
  1074  		// If the snap is free, then it doesn't need buying
  1075  		return false
  1076  	}
  1077  
  1078  	return !bought
  1079  }
  1080  
  1081  // A SnapSpec describes a single snap wanted from SnapInfo
  1082  type SnapSpec struct {
  1083  	Name string
  1084  }
  1085  
  1086  // SnapInfo returns the snap.Info for the store-hosted snap matching the given spec, or an error.
  1087  func (s *Store) SnapInfo(ctx context.Context, snapSpec SnapSpec, user *auth.UserState) (*snap.Info, error) {
  1088  	fields := strings.Join(s.infoFields, ",")
  1089  
  1090  	si, resp, err := s.snapInfo(ctx, snapSpec.Name, fields, user)
  1091  	if err != nil {
  1092  		return nil, err
  1093  	}
  1094  
  1095  	info, err := infoFromStoreInfo(si)
  1096  	if err != nil {
  1097  		return nil, err
  1098  	}
  1099  
  1100  	err = s.decorateOrders([]*snap.Info{info}, user)
  1101  	if err != nil {
  1102  		logger.Noticef("cannot get user orders: %v", err)
  1103  	}
  1104  
  1105  	s.extractSuggestedCurrency(resp)
  1106  
  1107  	return info, nil
  1108  }
  1109  
  1110  func (s *Store) snapInfo(ctx context.Context, snapName string, fields string, user *auth.UserState) (*storeInfo, *http.Response, error) {
  1111  	query := url.Values{}
  1112  	query.Set("fields", fields)
  1113  	query.Set("architecture", s.architecture)
  1114  
  1115  	u := s.endpointURL(path.Join(snapInfoEndpPath, snapName), query)
  1116  	reqOptions := &requestOptions{
  1117  		Method:   "GET",
  1118  		URL:      u,
  1119  		APILevel: apiV2Endps,
  1120  	}
  1121  
  1122  	var remote storeInfo
  1123  	resp, err := s.retryRequestDecodeJSON(ctx, reqOptions, user, &remote, nil)
  1124  	if err != nil {
  1125  		return nil, nil, err
  1126  	}
  1127  
  1128  	// check statusCode
  1129  	switch resp.StatusCode {
  1130  	case 200:
  1131  		// OK
  1132  	case 404:
  1133  		return nil, nil, ErrSnapNotFound
  1134  	default:
  1135  		msg := fmt.Sprintf("get details for snap %q", snapName)
  1136  		return nil, nil, respToError(resp, msg)
  1137  	}
  1138  
  1139  	return &remote, resp, err
  1140  }
  1141  
  1142  // SnapInfo checks whether the store-hosted snap matching the given spec exists and returns a reference with it name and snap-id and default channel, or an error.
  1143  func (s *Store) SnapExists(ctx context.Context, snapSpec SnapSpec, user *auth.UserState) (naming.SnapRef, *channel.Channel, error) {
  1144  	// request the minimal amount information
  1145  	fields := "channel-map"
  1146  
  1147  	si, _, err := s.snapInfo(ctx, snapSpec.Name, fields, user)
  1148  	if err != nil {
  1149  		return nil, nil, err
  1150  	}
  1151  
  1152  	return minimalFromStoreInfo(si)
  1153  }
  1154  
  1155  // A Search is what you do in order to Find something
  1156  type Search struct {
  1157  	// Query is a term to search by or a prefix (if Prefix is true)
  1158  	Query  string
  1159  	Prefix bool
  1160  
  1161  	CommonID string
  1162  
  1163  	// category is "section" in search v1
  1164  	Category string
  1165  	Private  bool
  1166  	Scope    string
  1167  }
  1168  
  1169  // Find finds  (installable) snaps from the store, matching the
  1170  // given Search.
  1171  func (s *Store) Find(ctx context.Context, search *Search, user *auth.UserState) ([]*snap.Info, error) {
  1172  	if search.Private && user == nil {
  1173  		return nil, ErrUnauthenticated
  1174  	}
  1175  
  1176  	searchTerm := strings.TrimSpace(search.Query)
  1177  
  1178  	// these characters might have special meaning on the search
  1179  	// server, and don't form part of a reasonable search, so
  1180  	// abort if they're included.
  1181  	//
  1182  	// "-" might also be special on the server, but it's also a
  1183  	// valid part of a package name, so we let it pass
  1184  	if strings.ContainsAny(searchTerm, `+=&|><!(){}[]^"~*?:\/`) {
  1185  		return nil, ErrBadQuery
  1186  	}
  1187  
  1188  	q := url.Values{}
  1189  	q.Set("fields", strings.Join(s.findFields, ","))
  1190  	q.Set("architecture", s.architecture)
  1191  
  1192  	if search.Private {
  1193  		q.Set("private", "true")
  1194  	}
  1195  
  1196  	if search.Prefix {
  1197  		q.Set("name", searchTerm)
  1198  	} else {
  1199  		if search.CommonID != "" {
  1200  			q.Set("common-id", search.CommonID)
  1201  		}
  1202  		if searchTerm != "" {
  1203  			q.Set("q", searchTerm)
  1204  		}
  1205  	}
  1206  
  1207  	if search.Category != "" {
  1208  		q.Set("category", search.Category)
  1209  	}
  1210  
  1211  	// with search v2 all risks are searched by default (same as scope=wide
  1212  	// with v1) so we need to restrict channel if scope is not passed.
  1213  	if search.Scope == "" {
  1214  		q.Set("channel", "stable")
  1215  	} else if search.Scope != "wide" {
  1216  		return nil, ErrInvalidScope
  1217  	}
  1218  
  1219  	if release.OnClassic {
  1220  		q.Set("confinement", "strict,classic")
  1221  	} else {
  1222  		q.Set("confinement", "strict")
  1223  	}
  1224  
  1225  	u := s.endpointURL(findEndpPath, q)
  1226  	reqOptions := &requestOptions{
  1227  		Method:   "GET",
  1228  		URL:      u,
  1229  		Accept:   jsonContentType,
  1230  		APILevel: apiV2Endps,
  1231  	}
  1232  
  1233  	var searchData searchV2Results
  1234  
  1235  	// TODO: use retryRequestDecodeJSON (may require content-type check there,
  1236  	// requires checking other handlers, their tests and store).
  1237  	doRequest := func() (*http.Response, error) {
  1238  		return s.doRequest(ctx, s.client, reqOptions, user)
  1239  	}
  1240  	readResponse := func(resp *http.Response) error {
  1241  		ok := (resp.StatusCode == 200 || resp.StatusCode == 201)
  1242  		ct := resp.Header.Get("Content-Type")
  1243  		// always decode on success; decode failures only if body is not empty
  1244  		if !ok && (resp.ContentLength == 0 || ct != jsonContentType) {
  1245  			return nil
  1246  		}
  1247  		return json.NewDecoder(resp.Body).Decode(&searchData)
  1248  	}
  1249  	resp, err := httputil.RetryRequest(u.String(), doRequest, readResponse, defaultRetryStrategy)
  1250  	if err != nil {
  1251  		return nil, err
  1252  	}
  1253  
  1254  	if resp.StatusCode != 200 {
  1255  		// fallback to search v1; v2 may not be available on some proxies
  1256  		if resp.StatusCode == 404 {
  1257  			verstr := resp.Header.Get("Snap-Store-Version")
  1258  			ver, err := strconv.Atoi(verstr)
  1259  			if err != nil {
  1260  				logger.Debugf("Bogus Snap-Store-Version header %q.", verstr)
  1261  			} else if ver < 20 {
  1262  				return s.findV1(ctx, search, user)
  1263  			}
  1264  		}
  1265  		if len(searchData.ErrorList) > 0 {
  1266  			if len(searchData.ErrorList) > 1 {
  1267  				logger.Noticef("unexpected number of errors (%d) when trying to search via %q", len(searchData.ErrorList), resp.Request.URL)
  1268  			}
  1269  			return nil, translateSnapActionError("", "", searchData.ErrorList[0].Code, searchData.ErrorList[0].Message, nil)
  1270  		}
  1271  		return nil, respToError(resp, "search")
  1272  	}
  1273  
  1274  	if ct := resp.Header.Get("Content-Type"); ct != jsonContentType {
  1275  		return nil, fmt.Errorf("received an unexpected content type (%q) when trying to search via %q", ct, resp.Request.URL)
  1276  	}
  1277  
  1278  	snaps := make([]*snap.Info, len(searchData.Results))
  1279  	for i, res := range searchData.Results {
  1280  		info, err := infoFromStoreSearchResult(res)
  1281  		if err != nil {
  1282  			return nil, err
  1283  		}
  1284  		snaps[i] = info
  1285  	}
  1286  
  1287  	err = s.decorateOrders(snaps, user)
  1288  	if err != nil {
  1289  		logger.Noticef("cannot get user orders: %v", err)
  1290  	}
  1291  
  1292  	s.extractSuggestedCurrency(resp)
  1293  
  1294  	return snaps, nil
  1295  }
  1296  
  1297  func (s *Store) findV1(ctx context.Context, search *Search, user *auth.UserState) ([]*snap.Info, error) {
  1298  	// search.Query is already verified for illegal characters by Find()
  1299  	searchTerm := strings.TrimSpace(search.Query)
  1300  	q := s.defaultSnapQuery()
  1301  
  1302  	if search.Private {
  1303  		q.Set("private", "true")
  1304  	}
  1305  
  1306  	if search.Prefix {
  1307  		q.Set("name", searchTerm)
  1308  	} else {
  1309  		if search.CommonID != "" {
  1310  			q.Set("common_id", search.CommonID)
  1311  		}
  1312  		if searchTerm != "" {
  1313  			q.Set("q", searchTerm)
  1314  		}
  1315  	}
  1316  
  1317  	// category was "section" in search v1
  1318  	if search.Category != "" {
  1319  		q.Set("section", search.Category)
  1320  	}
  1321  	if search.Scope != "" {
  1322  		q.Set("scope", search.Scope)
  1323  	}
  1324  
  1325  	if release.OnClassic {
  1326  		q.Set("confinement", "strict,classic")
  1327  	} else {
  1328  		q.Set("confinement", "strict")
  1329  	}
  1330  
  1331  	u := s.endpointURL(searchEndpPath, q)
  1332  	reqOptions := &requestOptions{
  1333  		Method: "GET",
  1334  		URL:    u,
  1335  		Accept: halJsonContentType,
  1336  	}
  1337  
  1338  	var searchData searchResults
  1339  	resp, err := s.retryRequestDecodeJSON(ctx, reqOptions, user, &searchData, nil)
  1340  	if err != nil {
  1341  		return nil, err
  1342  	}
  1343  
  1344  	if resp.StatusCode != 200 {
  1345  		return nil, respToError(resp, "search")
  1346  	}
  1347  
  1348  	if ct := resp.Header.Get("Content-Type"); ct != halJsonContentType {
  1349  		return nil, fmt.Errorf("received an unexpected content type (%q) when trying to search via %q", ct, resp.Request.URL)
  1350  	}
  1351  
  1352  	snaps := make([]*snap.Info, len(searchData.Payload.Packages))
  1353  	for i, pkg := range searchData.Payload.Packages {
  1354  		snaps[i] = infoFromRemote(pkg)
  1355  	}
  1356  
  1357  	err = s.decorateOrders(snaps, user)
  1358  	if err != nil {
  1359  		logger.Noticef("cannot get user orders: %v", err)
  1360  	}
  1361  
  1362  	s.extractSuggestedCurrency(resp)
  1363  
  1364  	return snaps, nil
  1365  }
  1366  
  1367  // Sections retrieves the list of available store sections.
  1368  func (s *Store) Sections(ctx context.Context, user *auth.UserState) ([]string, error) {
  1369  	reqOptions := &requestOptions{
  1370  		Method:         "GET",
  1371  		URL:            s.endpointURL(sectionsEndpPath, nil),
  1372  		Accept:         halJsonContentType,
  1373  		DeviceAuthNeed: deviceAuthCustomStoreOnly,
  1374  	}
  1375  
  1376  	var sectionData sectionResults
  1377  	resp, err := s.retryRequestDecodeJSON(context.TODO(), reqOptions, user, &sectionData, nil)
  1378  	if err != nil {
  1379  		return nil, err
  1380  	}
  1381  
  1382  	if resp.StatusCode != 200 {
  1383  		return nil, respToError(resp, "retrieve sections")
  1384  	}
  1385  
  1386  	if ct := resp.Header.Get("Content-Type"); ct != halJsonContentType {
  1387  		return nil, fmt.Errorf("received an unexpected content type (%q) when trying to retrieve the sections via %q", ct, resp.Request.URL)
  1388  	}
  1389  
  1390  	var sectionNames []string
  1391  	for _, s := range sectionData.Payload.Sections {
  1392  		sectionNames = append(sectionNames, s.Name)
  1393  	}
  1394  
  1395  	return sectionNames, nil
  1396  }
  1397  
  1398  // WriteCatalogs queries the "commands" endpoint and writes the
  1399  // command names into the given io.Writer.
  1400  func (s *Store) WriteCatalogs(ctx context.Context, names io.Writer, adder SnapAdder) error {
  1401  	u := *s.endpointURL(commandsEndpPath, nil)
  1402  
  1403  	q := u.Query()
  1404  	if release.OnClassic {
  1405  		q.Set("confinement", "strict,classic")
  1406  	} else {
  1407  		q.Set("confinement", "strict")
  1408  	}
  1409  
  1410  	u.RawQuery = q.Encode()
  1411  	reqOptions := &requestOptions{
  1412  		Method:         "GET",
  1413  		URL:            &u,
  1414  		Accept:         halJsonContentType,
  1415  		DeviceAuthNeed: deviceAuthCustomStoreOnly,
  1416  	}
  1417  
  1418  	// do not log body for catalog updates (its huge)
  1419  	client := s.newHTTPClient(&httputil.ClientOptions{
  1420  		MayLogBody: false,
  1421  		Timeout:    10 * time.Second,
  1422  	})
  1423  	doRequest := func() (*http.Response, error) {
  1424  		return s.doRequest(ctx, client, reqOptions, nil)
  1425  	}
  1426  	readResponse := func(resp *http.Response) error {
  1427  		return decodeCatalog(resp, names, adder)
  1428  	}
  1429  
  1430  	resp, err := httputil.RetryRequest(u.String(), doRequest, readResponse, defaultRetryStrategy)
  1431  	if err != nil {
  1432  		return err
  1433  	}
  1434  	if resp.StatusCode != 200 {
  1435  		return respToError(resp, "refresh commands catalog")
  1436  	}
  1437  
  1438  	return nil
  1439  }
  1440  
  1441  // SuggestedCurrency retrieves the cached value for the store's suggested currency
  1442  func (s *Store) SuggestedCurrency() string {
  1443  	s.mu.Lock()
  1444  	defer s.mu.Unlock()
  1445  
  1446  	if s.suggestedCurrency == "" {
  1447  		return "USD"
  1448  	}
  1449  	return s.suggestedCurrency
  1450  }
  1451  
  1452  // orderInstruction holds data sent to the store for orders.
  1453  type orderInstruction struct {
  1454  	SnapID   string `json:"snap_id"`
  1455  	Amount   string `json:"amount,omitempty"`
  1456  	Currency string `json:"currency,omitempty"`
  1457  }
  1458  
  1459  type storeError struct {
  1460  	Code    string `json:"code"`
  1461  	Message string `json:"message"`
  1462  }
  1463  
  1464  func (s *storeError) Error() string {
  1465  	return s.Message
  1466  }
  1467  
  1468  type storeErrors struct {
  1469  	Errors []*storeError `json:"error_list"`
  1470  }
  1471  
  1472  func (s *storeErrors) Code() string {
  1473  	if len(s.Errors) == 0 {
  1474  		return ""
  1475  	}
  1476  	return s.Errors[0].Code
  1477  }
  1478  
  1479  func (s *storeErrors) Error() string {
  1480  	if len(s.Errors) == 0 {
  1481  		return "internal error: empty store error used as an actual error"
  1482  	}
  1483  	return s.Errors[0].Error()
  1484  }
  1485  
  1486  func buyOptionError(message string) (*client.BuyResult, error) {
  1487  	return nil, fmt.Errorf("cannot buy snap: %s", message)
  1488  }
  1489  
  1490  // Buy sends a buy request for the specified snap.
  1491  // Returns the state of the order: Complete, Cancelled.
  1492  func (s *Store) Buy(options *client.BuyOptions, user *auth.UserState) (*client.BuyResult, error) {
  1493  	if options.SnapID == "" {
  1494  		return buyOptionError("snap ID missing")
  1495  	}
  1496  	if options.Price <= 0 {
  1497  		return buyOptionError("invalid expected price")
  1498  	}
  1499  	if options.Currency == "" {
  1500  		return buyOptionError("currency missing")
  1501  	}
  1502  	if user == nil {
  1503  		return nil, ErrUnauthenticated
  1504  	}
  1505  
  1506  	instruction := orderInstruction{
  1507  		SnapID:   options.SnapID,
  1508  		Amount:   fmt.Sprintf("%.2f", options.Price),
  1509  		Currency: options.Currency,
  1510  	}
  1511  
  1512  	jsonData, err := json.Marshal(instruction)
  1513  	if err != nil {
  1514  		return nil, err
  1515  	}
  1516  
  1517  	reqOptions := &requestOptions{
  1518  		Method:      "POST",
  1519  		URL:         s.endpointURL(buyEndpPath, nil),
  1520  		Accept:      jsonContentType,
  1521  		ContentType: jsonContentType,
  1522  		Data:        jsonData,
  1523  	}
  1524  
  1525  	var orderDetails order
  1526  	var errorInfo storeErrors
  1527  	resp, err := s.retryRequestDecodeJSON(context.TODO(), reqOptions, user, &orderDetails, &errorInfo)
  1528  	if err != nil {
  1529  		return nil, err
  1530  	}
  1531  
  1532  	switch resp.StatusCode {
  1533  	case 200, 201:
  1534  		// user already ordered or order successful
  1535  		if orderDetails.State == "Cancelled" {
  1536  			return buyOptionError("payment cancelled")
  1537  		}
  1538  
  1539  		return &client.BuyResult{
  1540  			State: orderDetails.State,
  1541  		}, nil
  1542  	case 400:
  1543  		// Invalid price was specified, etc.
  1544  		return buyOptionError(fmt.Sprintf("bad request: %v", errorInfo.Error()))
  1545  	case 403:
  1546  		// Customer account not set up for purchases.
  1547  		switch errorInfo.Code() {
  1548  		case "no-payment-methods":
  1549  			return nil, ErrNoPaymentMethods
  1550  		case "tos-not-accepted":
  1551  			return nil, ErrTOSNotAccepted
  1552  		}
  1553  		return buyOptionError(fmt.Sprintf("permission denied: %v", errorInfo.Error()))
  1554  	case 404:
  1555  		// Likely because customer account or snap ID doesn't exist.
  1556  		return buyOptionError(fmt.Sprintf("server says not found: %v", errorInfo.Error()))
  1557  	case 402: // Payment Required
  1558  		// Payment failed for some reason.
  1559  		return nil, ErrPaymentDeclined
  1560  	case 401:
  1561  		// TODO handle token expiry and refresh
  1562  		return nil, ErrInvalidCredentials
  1563  	default:
  1564  		return nil, respToError(resp, fmt.Sprintf("buy snap: %v", errorInfo))
  1565  	}
  1566  }
  1567  
  1568  type storeCustomer struct {
  1569  	LatestTOSDate     string `json:"latest_tos_date"`
  1570  	AcceptedTOSDate   string `json:"accepted_tos_date"`
  1571  	LatestTOSAccepted bool   `json:"latest_tos_accepted"`
  1572  	HasPaymentMethod  bool   `json:"has_payment_method"`
  1573  }
  1574  
  1575  // ReadyToBuy returns nil if the user's account has accepted T&Cs and has a payment method registered, and an error otherwise
  1576  func (s *Store) ReadyToBuy(user *auth.UserState) error {
  1577  	if user == nil {
  1578  		return ErrUnauthenticated
  1579  	}
  1580  
  1581  	reqOptions := &requestOptions{
  1582  		Method: "GET",
  1583  		URL:    s.endpointURL(customersMeEndpPath, nil),
  1584  		Accept: jsonContentType,
  1585  	}
  1586  
  1587  	var customer storeCustomer
  1588  	var errors storeErrors
  1589  	resp, err := s.retryRequestDecodeJSON(context.TODO(), reqOptions, user, &customer, &errors)
  1590  	if err != nil {
  1591  		return err
  1592  	}
  1593  
  1594  	switch resp.StatusCode {
  1595  	case 200:
  1596  		if !customer.HasPaymentMethod {
  1597  			return ErrNoPaymentMethods
  1598  		}
  1599  		if !customer.LatestTOSAccepted {
  1600  			return ErrTOSNotAccepted
  1601  		}
  1602  		return nil
  1603  	case 404:
  1604  		// Likely because user has no account registered on the pay server
  1605  		return fmt.Errorf("cannot get customer details: server says no account exists")
  1606  	case 401:
  1607  		return ErrInvalidCredentials
  1608  	default:
  1609  		if len(errors.Errors) == 0 {
  1610  			return fmt.Errorf("cannot get customer details: unexpected HTTP code %d", resp.StatusCode)
  1611  		}
  1612  		return &errors
  1613  	}
  1614  }
  1615  
  1616  // abbreviated info structs just for the download info
  1617  type storeInfoChannelAbbrev struct {
  1618  	Download storeSnapDownload `json:"download"`
  1619  }
  1620  
  1621  type storeInfoAbbrev struct {
  1622  	// discard anything beyond the first entry
  1623  	ChannelMap [1]storeInfoChannelAbbrev `json:"channel-map"`
  1624  }
  1625  
  1626  var errUnexpectedConnCheckResponse = errors.New("unexpected response during connection check")
  1627  
  1628  func (s *Store) snapConnCheck() ([]string, error) {
  1629  	var hosts []string
  1630  	// NOTE: "core" is possibly the only snap that's sure to be in all stores
  1631  	//       when we drop "core" in the move to snapd/core18/etc, change this
  1632  	infoURL := s.endpointURL(path.Join(snapInfoEndpPath, "core"), url.Values{
  1633  		// we only want the download URL
  1634  		"fields": {"download"},
  1635  		// we only need *one* (but can't filter by channel ... yet)
  1636  		"architecture": {s.architecture},
  1637  	})
  1638  	hosts = append(hosts, infoURL.Host)
  1639  
  1640  	var result storeInfoAbbrev
  1641  	resp, err := httputil.RetryRequest(infoURL.String(), func() (*http.Response, error) {
  1642  		return s.doRequest(context.TODO(), s.client, &requestOptions{
  1643  			Method:   "GET",
  1644  			URL:      infoURL,
  1645  			APILevel: apiV2Endps,
  1646  		}, nil)
  1647  	}, func(resp *http.Response) error {
  1648  		return decodeJSONBody(resp, &result, nil)
  1649  	}, connCheckStrategy)
  1650  
  1651  	if err != nil {
  1652  		return hosts, err
  1653  	}
  1654  	resp.Body.Close()
  1655  
  1656  	dlURLraw := result.ChannelMap[0].Download.URL
  1657  	dlURL, err := url.ParseRequestURI(dlURLraw)
  1658  	if err != nil {
  1659  		return hosts, err
  1660  	}
  1661  	hosts = append(hosts, dlURL.Host)
  1662  
  1663  	cdnHeader, err := s.cdnHeader()
  1664  	if err != nil {
  1665  		return hosts, err
  1666  	}
  1667  
  1668  	reqOptions := downloadReqOpts(dlURL, cdnHeader, nil)
  1669  	reqOptions.Method = "HEAD" // not actually a download
  1670  
  1671  	// TODO: We need the HEAD here so that we get redirected to the
  1672  	//       right CDN machine. Consider just doing a "net.Dial"
  1673  	//       after the redirect here. Suggested in
  1674  	// https://github.com/snapcore/snapd/pull/5176#discussion_r193437230
  1675  	resp, err = httputil.RetryRequest(dlURLraw, func() (*http.Response, error) {
  1676  		return s.doRequest(context.TODO(), s.client, reqOptions, nil)
  1677  	}, func(resp *http.Response) error {
  1678  		// account for redirect
  1679  		hosts[len(hosts)-1] = resp.Request.URL.Host
  1680  		return nil
  1681  	}, connCheckStrategy)
  1682  	if err != nil {
  1683  		return hosts, err
  1684  	}
  1685  	resp.Body.Close()
  1686  
  1687  	if resp.StatusCode != 200 {
  1688  		return hosts, errUnexpectedConnCheckResponse
  1689  	}
  1690  
  1691  	return hosts, nil
  1692  }
  1693  
  1694  func (s *Store) ConnectivityCheck() (status map[string]bool, err error) {
  1695  	status = make(map[string]bool)
  1696  
  1697  	checkers := []func() ([]string, error){
  1698  		s.snapConnCheck,
  1699  	}
  1700  
  1701  	for _, checker := range checkers {
  1702  		hosts, err := checker()
  1703  		for _, host := range hosts {
  1704  			status[host] = (err == nil)
  1705  		}
  1706  	}
  1707  
  1708  	return status, nil
  1709  }
  1710  
  1711  func (s *Store) CreateCohorts(ctx context.Context, snaps []string) (map[string]string, error) {
  1712  	jsonData, err := json.Marshal(map[string][]string{"snaps": snaps})
  1713  	if err != nil {
  1714  		return nil, err
  1715  	}
  1716  
  1717  	u := s.endpointURL(cohortsEndpPath, nil)
  1718  	reqOptions := &requestOptions{
  1719  		Method:   "POST",
  1720  		URL:      u,
  1721  		APILevel: apiV2Endps,
  1722  		Data:     jsonData,
  1723  	}
  1724  
  1725  	var remote struct {
  1726  		CohortKeys map[string]string `json:"cohort-keys"`
  1727  	}
  1728  	resp, err := s.retryRequestDecodeJSON(ctx, reqOptions, nil, &remote, nil)
  1729  	if err != nil {
  1730  		return nil, err
  1731  	}
  1732  	switch resp.StatusCode {
  1733  	case 200:
  1734  		// OK
  1735  	case 404:
  1736  		return nil, ErrSnapNotFound
  1737  	default:
  1738  		return nil, respToError(resp, fmt.Sprintf("create cohorts for %s", strutil.Quoted(snaps)))
  1739  	}
  1740  
  1741  	return remote.CohortKeys, nil
  1742  }