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