Permalink
Fetching contributors…
Cannot retrieve contributors at this time
770 lines (651 sloc) 20.5 KB
// -*- Mode: Go; indent-tabs-mode: t -*-
/*
* Copyright (C) 2014-2016 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Package store has support to use the Ubuntu Store for querying and downloading of snaps, and the related services.
package store
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"reflect"
"strings"
"sync"
"github.com/snapcore/snapd/arch"
"github.com/snapcore/snapd/asserts"
"github.com/snapcore/snapd/logger"
"github.com/snapcore/snapd/progress"
"github.com/snapcore/snapd/release"
"github.com/snapcore/snapd/snap"
)
// TODO: better/shorter names are probably in order once fewer legacy places are using this
const (
// UbuntuCoreWireProtocol is the protocol level we support when
// communicating with the store. History:
// - "1": client supports squashfs snaps
UbuntuCoreWireProtocol = "1"
)
func infoFromRemote(d snapDetails) *snap.Info {
info := &snap.Info{}
info.Architectures = d.Architectures
info.Type = d.Type
info.Version = d.Version
info.Epoch = "0"
info.OfficialName = d.Name
info.SnapID = d.SnapID
info.Revision = d.Revision
info.EditedSummary = d.Summary
info.EditedDescription = d.Description
info.Developer = d.Developer
info.Channel = d.Channel
info.Sha512 = d.DownloadSha512
info.Size = d.DownloadSize
info.IconURL = d.IconURL
info.AnonDownloadURL = d.AnonDownloadURL
info.DownloadURL = d.DownloadURL
info.Prices = d.Prices
info.Private = d.Private
return info
}
// SnapUbuntuStoreConfig represents the configuration to access the snap store
type SnapUbuntuStoreConfig struct {
SearchURI *url.URL
BulkURI *url.URL
AssertionsURI *url.URL
PurchasesURI *url.URL
}
// SnapUbuntuStoreRepository represents the ubuntu snap store
type SnapUbuntuStoreRepository struct {
storeID string
searchURI *url.URL
bulkURI *url.URL
assertionsURI *url.URL
purchasesURI *url.URL
// reused http client
client *http.Client
mu sync.Mutex
suggestedCurrency string
}
func getStructFields(s interface{}) []string {
st := reflect.TypeOf(s)
num := st.NumField()
fields := make([]string, 0, num)
for i := 0; i < num; i++ {
tag := st.Field(i).Tag.Get("json")
idx := strings.IndexRune(tag, ',')
if idx > -1 {
tag = tag[:idx]
}
if tag != "" {
fields = append(fields, tag)
}
}
return fields
}
func cpiURL() string {
if os.Getenv("SNAPPY_USE_STAGING_CPI") != "" {
return "https://search.apps.staging.ubuntu.com/api/v1/"
}
// FIXME: this will become a store-url assertion
if os.Getenv("SNAPPY_FORCE_CPI_URL") != "" {
return os.Getenv("SNAPPY_FORCE_CPI_URL")
}
return "https://search.apps.ubuntu.com/api/v1/"
}
func authURL() string {
if os.Getenv("SNAPPY_USE_STAGING_CPI") != "" {
return "https://login.staging.ubuntu.com/api/v2"
}
return "https://login.ubuntu.com/api/v2"
}
func assertsURL() string {
if os.Getenv("SNAPPY_USE_STAGING_SAS") != "" {
return "https://assertions.staging.ubuntu.com/v1/"
}
if os.Getenv("SNAPPY_FORCE_SAS_URL") != "" {
return os.Getenv("SNAPPY_FORCE_SAS_URL")
}
return "https://assertions.ubuntu.com/v1/"
}
func myappsURL() string {
if os.Getenv("SNAPPY_USE_STAGING_MYAPPS") != "" {
return "https://myapps.developer.staging.ubuntu.com/"
}
return "https://myapps.developer.ubuntu.com/"
}
var defaultConfig = SnapUbuntuStoreConfig{}
func init() {
storeBaseURI, err := url.Parse(cpiURL())
if err != nil {
panic(err)
}
defaultConfig.SearchURI, err = storeBaseURI.Parse("search")
if err != nil {
panic(err)
}
v := url.Values{}
v.Set("fields", strings.Join(getStructFields(snapDetails{}), ","))
defaultConfig.SearchURI.RawQuery = v.Encode()
defaultConfig.BulkURI, err = storeBaseURI.Parse("metadata")
if err != nil {
panic(err)
}
defaultConfig.BulkURI.RawQuery = v.Encode()
assertsBaseURI, err := url.Parse(assertsURL())
if err != nil {
panic(err)
}
defaultConfig.AssertionsURI, err = assertsBaseURI.Parse("assertions/")
if err != nil {
panic(err)
}
defaultConfig.PurchasesURI, err = url.Parse(myappsURL() + "dev/api/snap-purchases/")
if err != nil {
panic(err)
}
}
type searchResults struct {
Payload struct {
Packages []snapDetails `json:"clickindex:package"`
} `json:"_embedded"`
}
// NewUbuntuStoreSnapRepository creates a new SnapUbuntuStoreRepository with the given access configuration and for given the store id.
func NewUbuntuStoreSnapRepository(cfg *SnapUbuntuStoreConfig, storeID string) *SnapUbuntuStoreRepository {
if cfg == nil {
cfg = &defaultConfig
}
// see https://wiki.ubuntu.com/AppStore/Interfaces/ClickPackageIndex
return &SnapUbuntuStoreRepository{
storeID: storeID,
searchURI: cfg.SearchURI,
bulkURI: cfg.BulkURI,
assertionsURI: cfg.AssertionsURI,
purchasesURI: cfg.PurchasesURI,
client: &http.Client{
Transport: &LoggedTransport{
Transport: http.DefaultTransport,
Key: "SNAPD_DEBUG_HTTP",
},
},
}
}
// small helper that sets the correct http headers for the ubuntu store
func (s *SnapUbuntuStoreRepository) setUbuntuStoreHeaders(req *http.Request, channel string, auther Authenticator) {
if auther != nil {
auther.Authenticate(req)
}
req.Header.Set("Accept", "application/hal+json,application/json")
req.Header.Set("X-Ubuntu-Architecture", string(arch.UbuntuArchitecture()))
req.Header.Set("X-Ubuntu-Release", release.Series)
req.Header.Set("X-Ubuntu-Wire-Protocol", UbuntuCoreWireProtocol)
if channel != "" {
req.Header.Set("X-Ubuntu-Device-Channel", channel)
}
if s.storeID != "" {
req.Header.Set("X-Ubuntu-Store", s.storeID)
}
}
// read all the available metadata from the store response and cache
func (s *SnapUbuntuStoreRepository) checkStoreResponse(resp *http.Response) {
suggestedCurrency := resp.Header.Get("X-Suggested-Currency")
if suggestedCurrency != "" {
s.mu.Lock()
s.suggestedCurrency = suggestedCurrency
s.mu.Unlock()
}
}
// purchase encapsulates the purchase data sent to us from the software center agent.
//
// When making a purchase request, the State "InProgress", together with a RedirectTo
// URL may be received. In-this case, the user must be directed to that webpage in
// order to complete the purchase (e.g. to enter 3D-secure credentials).
// Additionally, Partner ID may be recieved as an extended header "X-Partner-Id",
// this should be included in the follow-on requests to the redirect URL.
//
// HTTP/1.1 200 OK
// Content-Type: application/json; charset=utf-8
//
// [
// {
// "open_id": "https://login.staging.ubuntu.com/+id/open_id",
// "snap_id": "8nzc1x4iim2xj1g2ul64",
// "refundable_until": "2015-07-15 18:46:21",
// "state": "Complete"
// },
// {
// "open_id": "https://login.staging.ubuntu.com/+id/open_id",
// "snap_id": "8nzc1x4iim2xj1g2ul64",
// "item_sku": "item-1-sku",
// "purchase_id": "1",
// "refundable_until": null,
// "state": "Complete"
// },
// {
// "open_id": "https://login.staging.ubuntu.com/+id/open_id",
// "snap_id": "12jdhg1j2dgj12dgk1jh",
// "refundable_until": "2015-07-17 11:33:29",
// "state": "Complete"
// }
// ]
type purchase struct {
OpenID string `json:"open_id"`
SnapID string `json:"snap_id"`
RefundableUntil string `json:"refundable_until"`
State string `json:"state"`
ItemSKU string `json:"item_sku,omitempty"`
PurchaseID string `json:"purchase_id,omitempty"`
RedirectTo string `json:"redirect_to,omitempty"`
}
func (s *SnapUbuntuStoreRepository) getPurchasesFromURL(url *url.URL, channel string, auther Authenticator) ([]*purchase, error) {
if auther == nil {
return nil, fmt.Errorf("cannot obtain known purchases from store: no authentication credentials provided")
}
req, err := http.NewRequest("GET", url.String(), nil)
if err != nil {
return nil, err
}
s.setUbuntuStoreHeaders(req, channel, auther)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var purchases []*purchase
switch resp.StatusCode {
case http.StatusOK:
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&purchases); err != nil {
return nil, fmt.Errorf("cannot decode known purchases from store: %v", err)
}
case http.StatusUnauthorized:
// TODO handle token expiry and refresh
return nil, ErrInvalidCredentials
default:
return nil, fmt.Errorf("cannot obtain known purchases from store: server returned %v code", resp.StatusCode)
}
return purchases, nil
}
func setMustBuy(snaps []*snap.Info) {
for _, info := range snaps {
if len(info.Prices) != 0 {
info.MustBuy = true
}
}
}
func hasPriced(snaps []*snap.Info) bool {
// Search through the list of snaps to see if any are priced
for _, info := range snaps {
if len(info.Prices) != 0 {
return true
}
}
return false
}
// decorateAllPurchases sets the MustBuy property of each snap in the given list according to the user's known purchases.
func (s *SnapUbuntuStoreRepository) decoratePurchases(snaps []*snap.Info, channel string, auther Authenticator) error {
// Mark every non-free snap as must buy until we know better.
setMustBuy(snaps)
if auther == nil {
return nil
}
if !hasPriced(snaps) {
return nil
}
var err error
var purchasesURL *url.URL
if len(snaps) == 1 {
// If we only have a single snap, we should only find the purchases for that snap
purchasesURL, err = s.purchasesURI.Parse(snaps[0].SnapID + "/")
if err != nil {
return err
}
q := purchasesURL.Query()
q.Set("include_item_purchases", "true")
purchasesURL.RawQuery = q.Encode()
} else {
// Inconsistently, global search implies include_item_purchases.
purchasesURL = s.purchasesURI
}
purchases, err := s.getPurchasesFromURL(purchasesURL, channel, auther)
if err != nil {
return err
}
// Group purchases by snap ID.
purchasesByID := make(map[string][]*purchase)
for _, purchase := range purchases {
purchasesByID[purchase.SnapID] = append(purchasesByID[purchase.SnapID], purchase)
}
for _, info := range snaps {
info.MustBuy = mustBuy(info.Prices, purchasesByID[info.SnapID])
}
return nil
}
// mustBuy determines if a snap requires a payment, based on if it is non-free and if the user has already bought it
func mustBuy(prices map[string]float64, purchases []*purchase) bool {
if len(prices) == 0 {
// If the snap is free, then it doesn't need purchasing
return false
}
// Search through all the purchases for a snap to see if there are any
// that are for the whole snap, and not an "in-app" purchase.
for _, purchase := range purchases {
if purchase.ItemSKU == "" {
// Purchase is for the whole snap.
return false
}
}
// The snap is not free, and we couldn't find a purchase for the whole snap.
return true
}
// Snap returns the snap.Info for the store hosted snap with the given name or an error.
func (s *SnapUbuntuStoreRepository) Snap(name, channel string, auther Authenticator) (*snap.Info, error) {
u := *s.searchURI // make a copy, so we can mutate it
q := u.Query()
// exact match search
q.Set("q", "package_name:\""+name+"\"")
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
// set headers
s.setUbuntuStoreHeaders(req, channel, auther)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// check statusCode
switch {
case resp.StatusCode == 404:
return nil, ErrSnapNotFound
case resp.StatusCode != 200:
tpl := "Ubuntu CPI service returned unexpected HTTP status code %d while looking for snap %q in channel %q"
if oops := resp.Header.Get("X-Oops-Id"); oops != "" {
tpl += " [%s]"
return nil, fmt.Errorf(tpl, resp.StatusCode, name, channel, oops)
}
return nil, fmt.Errorf(tpl, resp.StatusCode, name, channel)
}
// and decode json
var searchData searchResults
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&searchData); err != nil {
return nil, err
}
switch len(searchData.Payload.Packages) {
case 0:
return nil, ErrSnapNotFound
case 1:
// whee
default:
logger.Noticef("expected at most one exact match search result for %q in %q channel, got %d.", name, channel, len(searchData.Payload.Packages))
return nil, fmt.Errorf("unexpected multiple store results for an exact match search for %q in %q channel", name, channel)
}
s.checkStoreResponse(resp)
info := infoFromRemote(searchData.Payload.Packages[0])
err = s.decoratePurchases([]*snap.Info{info}, channel, auther)
if err != nil {
logger.Noticef("cannot get user purchases: %v", err)
}
return info, nil
}
// Find finds (installable) snaps from the store, matching the
// given search term.
func (s *SnapUbuntuStoreRepository) Find(searchTerm string, channel string, auther Authenticator) ([]*snap.Info, error) {
if channel == "" {
channel = "stable"
}
u := *s.searchURI // make a copy, so we can mutate it
q := u.Query()
q.Set("q", searchTerm)
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
// set headers
s.setUbuntuStoreHeaders(req, channel, auther)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("received an unexpected http response code (%v) when trying to search via %q", resp.Status, req.URL)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/hal+json" {
return nil, fmt.Errorf("received an unexpected content type (%q) when trying to search via %q", ct, req.URL)
}
var searchData searchResults
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&searchData); err != nil {
return nil, fmt.Errorf("cannot decode reply (got %v) when trying to search via %q", err, req.URL)
}
snaps := make([]*snap.Info, len(searchData.Payload.Packages))
for i, pkg := range searchData.Payload.Packages {
snaps[i] = infoFromRemote(pkg)
}
err = s.decoratePurchases(snaps, channel, auther)
if err != nil {
logger.Noticef("cannot get user purchases: %v", err)
}
s.checkStoreResponse(resp)
return snaps, nil
}
// RefreshCandidate contains information for the store about the currently
// installed snap so that the store can decide what update we should see
type RefreshCandidate struct {
SnapID string
Revision snap.Revision
Epoch string
DevMode bool
// the desired channel
Channel string
}
// the exact bits that we need to send to the store
type currentSnapJson struct {
SnapID string `json:"snap_id"`
Channel string `json:"channel"`
Revision int `json:"revision,omitempty"`
Epoch string `json:"epoch"`
// The store expects a "confinement" value {"strict", "devmode"}.
// We map this accordingly from our devmode bool, we do not
// use the value of the current snap as we are interested in the
// users intention, not the actual value of the snap itself.
Confinement snap.ConfinementType `json:"confinement"`
}
type metadataWrapper struct {
Snaps []currentSnapJson `json:"snaps"`
Fields []string `json:"fields"`
}
// ListRefresh returns the available updates for a list of snap identified by fullname with channel.
func (s *SnapUbuntuStoreRepository) ListRefresh(installed []*RefreshCandidate, auther Authenticator) (snaps []*snap.Info, err error) {
candidateMap := map[string]*RefreshCandidate{}
currentSnaps := make([]currentSnapJson, 0, len(installed))
for _, cs := range installed {
revision := cs.Revision.N
if !cs.Revision.Store() {
revision = 0
}
// the store gets confused if we send snaps without a snapid
// (like local ones)
if cs.SnapID == "" {
continue
}
confinement := snap.StrictConfinement
if cs.DevMode {
confinement = snap.DevmodeConfinement
}
currentSnaps = append(currentSnaps, currentSnapJson{
SnapID: cs.SnapID,
Channel: cs.Channel,
Confinement: confinement,
Epoch: cs.Epoch,
Revision: revision,
})
candidateMap[cs.SnapID] = cs
}
// build input for the updates endpoint
jsonData, err := json.Marshal(metadataWrapper{
Snaps: currentSnaps,
// TODO: the store expects "origin" currently, we really want
// it to take "developer" instead
Fields: []string{"snap_id", "package_name", "revision", "version", "download_url", "origin"},
})
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", s.bulkURI.String(), bytes.NewBuffer([]byte(jsonData)))
if err != nil {
return nil, err
}
// set headers
// the updates call is a special snowflake right now
// (see LP: #1427155)
s.setUbuntuStoreHeaders(req, "", auther)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var updateData searchResults
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&updateData); err != nil {
return nil, err
}
res := make([]*snap.Info, 0, len(updateData.Payload.Packages))
for _, rsnap := range updateData.Payload.Packages {
// the store also gives us identical revisions, filter those
// out, we are not interested
if rsnap.Revision == candidateMap[rsnap.SnapID].Revision {
continue
}
res = append(res, infoFromRemote(rsnap))
}
s.checkStoreResponse(resp)
return res, nil
}
// Download downloads the given snap and returns its filename.
// The file is saved in temporary storage, and should be removed
// after use to prevent the disk from running out of space.
func (s *SnapUbuntuStoreRepository) Download(remoteSnap *snap.Info, pbar progress.Meter, auther Authenticator) (path string, err error) {
w, err := ioutil.TempFile("", remoteSnap.Name())
if err != nil {
return "", err
}
defer func() {
if cerr := w.Close(); cerr != nil && err == nil {
err = cerr
}
if err != nil {
os.Remove(w.Name())
path = ""
}
}()
url := remoteSnap.AnonDownloadURL
if url == "" || auther != nil {
url = remoteSnap.DownloadURL
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
s.setUbuntuStoreHeaders(req, "", auther)
if err := download(remoteSnap.Name(), w, req, pbar); err != nil {
return "", err
}
return w.Name(), w.Sync()
}
// download writes an http.Request showing a progress.Meter
var download = func(name string, w io.Writer, req *http.Request, pbar progress.Meter) error {
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return &ErrDownload{Code: resp.StatusCode, URL: req.URL}
}
if pbar != nil {
pbar.Start(name, float64(resp.ContentLength))
mw := io.MultiWriter(w, pbar)
_, err = io.Copy(mw, resp.Body)
pbar.Finished()
} else {
_, err = io.Copy(w, resp.Body)
}
return err
}
type assertionSvcError struct {
Status int `json:"status"`
Type string `json:"type"`
Title string `json:"title"`
Detail string `json:"detail"`
}
// Assertion retrivies the assertion for the given type and primary key.
func (s *SnapUbuntuStoreRepository) Assertion(assertType *asserts.AssertionType, primaryKey []string, auther Authenticator) (asserts.Assertion, error) {
url, err := s.assertionsURI.Parse(path.Join(assertType.Name, path.Join(primaryKey...)))
if err != nil {
return nil, err
}
req, err := http.NewRequest("GET", url.String(), nil)
if err != nil {
return nil, err
}
if auther != nil {
auther.Authenticate(req)
}
req.Header.Set("Accept", asserts.MediaType)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.Header.Get("Content-Type") == "application/json" {
var svcErr assertionSvcError
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&svcErr); err != nil {
return nil, fmt.Errorf("cannot decode assertion service error with HTTP status code %d: %v", resp.StatusCode, err)
}
if svcErr.Status == 404 {
return nil, ErrAssertionNotFound
}
return nil, fmt.Errorf("assertion service error: [%s] %q", svcErr.Title, svcErr.Detail)
}
return nil, fmt.Errorf("unexpected HTTP status code %d", resp.StatusCode)
}
// and decode assertion
dec := asserts.NewDecoder(resp.Body)
return dec.Decode()
}
// SuggestedCurrency retrieves the cached value for the store's suggested currency
func (s *SnapUbuntuStoreRepository) SuggestedCurrency() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.suggestedCurrency == "" {
return "USD"
}
return s.suggestedCurrency
}