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, §ionData, 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 }