launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #20324
Re: [Merge] lp:~cjwatson/launchpad/snap-store-client into lp:launchpad
Review: Approve code
Diff comments:
>
> === added file 'lib/lp/snappy/interfaces/snapstoreclient.py'
> --- lib/lp/snappy/interfaces/snapstoreclient.py 1970-01-01 00:00:00 +0000
> +++ lib/lp/snappy/interfaces/snapstoreclient.py 2016-05-03 19:57:26 +0000
> @@ -0,0 +1,43 @@
> +# Copyright 2016 Canonical Ltd. This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Interface for communication with the snap store."""
> +
> +from __future__ import absolute_import, print_function, unicode_literals
> +
> +__metaclass__ = type
> +__all__ = [
> + 'BadRequestPackageUploadResponse',
> + 'BadUploadResponse',
> + 'ISnapStoreClient',
> + ]
> +
> +from zope.interface import Interface
> +
> +
> +class BadRequestPackageUploadResponse(Exception):
> + pass
> +
> +
> +class BadUploadResponse(Exception):
> + pass
> +
> +
> +class ISnapStoreClient(Interface):
> + """Interface for the API provided by the snap store."""
> +
> + def requestPackageUpload(snap_series, snap_name):
It's requesting upload permission, not an actual upload. Might be worth noting that it doesn't handle third party caveats itself, so the macaroon isn't exactly appropriate for uploading builds yet.
> + """Request permission from the store to upload builds of a snap.
> +
> + :param snap_series: The `ISnapSeries` in which this snap should be
> + published on the store.
> + :param snap_name: The registered name of this snap on the store.
> + :return: A serialized macaroon appropriate for uploading builds of
> + this snap.
> + """
> +
> + def upload(snapbuild):
> + """Upload a snap build to the store.
> +
> + :param snapbuild: The `ISnapBuild` to upload.
> + """
>
> === added file 'lib/lp/snappy/model/snapstoreclient.py'
> --- lib/lp/snappy/model/snapstoreclient.py 1970-01-01 00:00:00 +0000
> +++ lib/lp/snappy/model/snapstoreclient.py 2016-05-03 19:57:26 +0000
> @@ -0,0 +1,143 @@
> +# Copyright 2016 Canonical Ltd. This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Communication with the snap store."""
> +
> +from __future__ import absolute_import, print_function, unicode_literals
> +
> +__metaclass__ = type
> +__all__ = [
> + 'SnapStoreClient',
> + ]
> +
> +try:
> + from urllib.parse import quote_plus
> +except ImportError:
> + from urllib import quote_plus
> +
> +from lazr.restful.utils import get_current_browser_request
> +import requests
> +from requests_toolbelt import MultipartEncoder
> +from zope.interface import implementer
> +
> +from lp.services.config import config
> +from lp.services.timeline.requesttimeline import get_request_timeline
> +from lp.services.timeout import urlfetch
> +from lp.services.webapp.url import urlappend
> +from lp.snappy.interfaces.snapstoreclient import (
> + BadRequestPackageUploadResponse,
> + BadUploadResponse,
> + ISnapStoreClient,
> + )
> +
> +
> +class LibraryFileAliasWrapper:
> + """A `LibraryFileAlias` wrapper usable with a `MultipartEncoder`."""
> +
> + def __init__(self, lfa):
> + self.lfa = lfa
> + self.position = 0
> +
> + @property
> + def len(self):
> + return self.lfa.content.filesize - self.position
If that's what it needs...
> +
> + def read(self, length=-1):
> + chunksize = None if length == -1 else length
> + data = self.lfa.read(chunksize=chunksize)
> + if chunksize is None:
> + self.position = self.lfa.content.filesize
> + else:
> + self.position += length
> + return data
> +
> +
> +class MacaroonAuth(requests.auth.AuthBase):
> + """Attaches macaroon authentication to a given Request object."""
> +
> + def __init__(self, tokens):
> + self.tokens = tokens
> +
> + def __call__(self, r):
> + r.headers["Authorization"] = (
> + "Macaroon " +
> + ", ".join('%s="%s"' % (k, v) for k, v in self.tokens.items()))
Perhaps check each token's content to see if it's going to break framing or not. We don't expect it to, but let's be surprised by DoS rather than privilege escalation.
> + return r
> +
> +
> +@implementer(ISnapStoreClient)
> +class SnapStoreClient:
> + """A client for the API provided by the snap store."""
> +
> + def requestPackageUpload(self, snap_series, snap_name):
> + assert config.snappy.store_url is not None
> + request_url = urlappend(
> + config.snappy.store_url, "api/2.0/acl/package_upload/")
> + request = get_current_browser_request()
> + timeline_action = get_request_timeline(request).start(
> + "request-snap-upload-macaroon",
> + "%s/%s" % (snap_series.name, snap_name), allow_nested=True)
> + try:
> + response = urlfetch(
> + request_url, method="POST",
> + json={"name": snap_name, "series": snap_series.name})
> + response_data = response.json()
> + if "macaroon" not in response_data:
> + raise BadRequestPackageUploadResponse(response.text)
> + return response_data["macaroon"]
> + except requests.HTTPError as e:
> + raise BadRequestPackageUploadResponse(e.response.text)
Include code as well?
> + finally:
> + timeline_action.finish()
> +
> + def _uploadFile(self, lfa, lfc):
> + """Upload a single file."""
> + assert config.snappy.store_upload_url is not None
> + unscanned_upload_url = urlappend(
> + config.snappy.store_upload_url, "unscanned-upload/")
> + lfa.open()
> + try:
> + lfa_wrapper = LibraryFileAliasWrapper(lfa)
> + encoder = MultipartEncoder(
> + fields={
> + "binary": (
> + "filename", lfa_wrapper, "application/octet-stream"),
> + })
> + try:
> + response = urlfetch(
> + unscanned_upload_url, method="POST", data=encoder,
> + headers={"Content-Type": encoder.content_type})
Timeline here as well?
> + response_data = response.json()
> + if not response_data.get("successful", False):
> + raise BadUploadResponse(response.text)
> + return {"upload_id": response_data["upload_id"]}
> + except requests.HTTPError as e:
> + raise BadUploadResponse(e.response.text)
> + finally:
> + lfa.close()
> +
> + def _uploadApp(self, snap, upload_data):
> + """Create a new store upload based on the uploaded file."""
> + assert config.snappy.store_url is not None
> + assert snap.store_name is not None
> + upload_url = urlappend(
> + config.snappy.store_url,
> + "dev/api/snap-upload/%s/" % quote_plus(snap.store_name))
> + data = {
> + "updown_id": upload_data["upload_id"],
> + "series": snap.store_series.name,
> + }
> + # XXX cjwatson 2016-04-20: handle refresh
> + try:
> + assert snap.store_tokens is not None
> + urlfetch(
> + upload_url, method="POST", data=data,
> + auth=MacaroonAuth(snap.store_tokens))
More timeline?
> + except requests.HTTPError as e:
> + raise BadUploadResponse(e.response.text)
> +
> + def upload(self, snapbuild):
> + """See `ISnapStoreClient`."""
> + for _, lfa, lfc in snapbuild.getFiles():
> + upload_data = self._uploadFile(lfa, lfc)
> + self._uploadApp(snapbuild.snap, upload_data)
--
https://code.launchpad.net/~cjwatson/launchpad/snap-store-client/+merge/293668
Your team Launchpad code reviewers is subscribed to branch lp:launchpad.
References