CenterForOpenScience/waterbutler

View on GitHub
waterbutler/providers/owncloud/provider.py

Summary

Maintainability
A
2 hrs
Test Coverage
import aiohttp

from waterbutler.core import streams
from waterbutler.core import provider
from waterbutler.core import exceptions
from waterbutler.core.path import WaterButlerPath

from waterbutler.providers.owncloud import utils
from waterbutler.providers.owncloud.metadata import OwnCloudFileRevisionMetadata


class OwnCloudProvider(provider.BaseProvider):
    """Provider for the ownCloud cloud storage service.

    This provider uses WebDAV for communication.

    API docs::

    * WebDAV: http://www.webdav.org/specs/rfc4918.html
    * OCSv1.7: https://www.freedesktop.org/wiki/Specifications/open-collaboration-services-1.7/

    Required settings fields::

    * folder
    * verify_ssl

    Required credentials fields::

    * host
    * username
    * password

    Quirks:

    * User credentials are stored in a aiohttp.BasicAuth object. At the moment, there isn't a
      better way to do this.
    """
    NAME = 'owncloud'

    def __init__(self, auth, credentials, settings, **kwargs):
        super().__init__(auth, credentials, settings, **kwargs)

        self.folder = settings['folder']
        if not self.folder.endswith('/'):
            self.folder += '/'

        self.verify_ssl = settings['verify_ssl']
        self.url = credentials['host']
        self._auth = aiohttp.BasicAuth(credentials['username'], credentials['password'])
        self.metrics.add('host', self.url)

    def connector(self):
        return aiohttp.TCPConnector(ssl=self.verify_ssl)

    @property
    def _webdav_url_(self):
        """Formats the outgoing url appropriately. This accounts for some differences in oc server
        software.
        """
        if self.url[-1] != '/':
            return self.url + '/remote.php/webdav/'
        return self.url + 'remote.php/webdav/'

    def shares_storage_root(self, other):
        """Owncloud settings only include the root folder. If a cross-resource move occurs
        between two owncloud providers that are on different accounts but have the same folder
        base name, the parent method could incorrectly think the action is a self-overwrite.
        Comparing credentials means that this is unique per connected account.

        :param waterbutler.core.provider.BaseProvider other: another provider to test
        :return: `True` if both providers share the same storage root
        :rtype: `bool`
        """
        return super().shares_storage_root(other) and self.credentials == other.credentials

    async def validate_v1_path(self, path, **kwargs):
        """Verifies that ``path`` exists and if so, returns a WaterButlerPath object that
        represents it. WebDAV returns 200 for a single file, 207 for a multipart (folder), and 404
        for Does Not Exist.

        :param str path: user-supplied path to validate
        :return: WaterButlerPath object representing ``path``
        :rtype: `waterbutler.core.path.WaterButlerPath`
        :raises `waterbutler.core.exceptions.NotFoundError`: if the path doesn't exist
        """
        if path == '/':
            return WaterButlerPath(path, prepend=self.folder)
        full_path = WaterButlerPath(path, prepend=self.folder)

        response = await self.make_request('PROPFIND',
            self._webdav_url_ + full_path.full_path,
            expects=(200, 207, 404),
            throws=exceptions.MetadataError,
            auth=self._auth,
            connector=self.connector(),
        )
        content = await response.content.read()
        await response.release()
        if response.status == 404:
            raise exceptions.NotFoundError(str(full_path.full_path))

        try:
            item = await utils.parse_dav_response(content, '/')
        except exceptions.NotFoundError:
            # Re-raise with the proper path
            raise exceptions.NotFoundError(str(full_path.full_path))
        if full_path.kind != item[0].kind:
            raise exceptions.NotFoundError(full_path.full_path)
        return full_path

    async def validate_path(self, path, **kwargs):
        """Similar to `validate_v1_path`, but will not throw a 404 if the path doesn't yet exist.
        Instead, returns a WaterButlerPath object for the potential path (such as before uploads).

        :param str path: user-supplied path to validate
        :return: WaterButlerPath object representing ``path``
        :rtype: :class:`waterbutler.core.path.WaterButlerPath`
        """
        if path == '/':
            return WaterButlerPath(path, prepend=self.folder)
        full_path = WaterButlerPath(path, prepend=self.folder)
        response = await self.make_request('PROPFIND',
            self._webdav_url_ + full_path.full_path,
            expects=(200, 207, 404),
            throws=exceptions.MetadataError,
            auth=self._auth,
            connector=self.connector(),
        )
        content = await response.content.read()
        await response.release()

        try:
            await utils.parse_dav_response(content, '/')
        except exceptions.NotFoundError:
            pass
        return full_path

    async def download(self, path, accept_url=False, range=None, **kwargs):
        """Creates a stream for downloading files from the remote host. If the metadata query for
        the file has no size metadata, downloads to memory.

        :param waterbutler.core.path.WaterButlerPath path: user-supplied path to download
        :raises: `waterbutler.core.exceptions.DownloadError`
        """

        self.metrics.add('download', {
            'got_accept_url': accept_url is False,
            'got_range': range is not None,
        })
        download_resp = await self.make_request(
            'GET',
            self._webdav_url_ + path.full_path,
            range=range,
            expects=(200, 206,),
            throws=exceptions.DownloadError,
            auth=self._auth,
            connector=self.connector(),
        )
        return streams.ResponseStreamReader(download_resp)

    async def upload(self, stream, path, conflict='replace', **kwargs):
        """Utilizes default name conflict handling behavior then adds the appropriate headers and
        creates the upload request.

        :param waterbutler.core.streams.RequestStreamReader stream: stream containing file contents
        :param waterbutler.core.path.WaterButlerPath path: user-supplied path to upload to
        :raises: `waterbutler.core.exceptions.UploadError`
        """
        if path.identifier and conflict == 'keep':
            path, _ = await self.handle_name_conflict(path, conflict=conflict, kind='folder')
            path._parts[-1]._id = None

        response = await self.make_request(
            'PUT',
            self._webdav_url_ + path.full_path,
            data=stream,
            headers={'Content-Length': str(stream.size)},
            expects=(201, 204,),
            throws=exceptions.UploadError,
            auth=self._auth,
            connector=self.connector(),
        )
        await response.release()
        meta = await self.metadata(path)
        return meta, response.status == 201

    async def delete(self, path, **kwargs):
        """Deletes ``path`` on remote host

        :param waterbutler.core.path.WaterButlerPath path: user-supplied path to delete
        :raises: `waterbutler.core.exceptions.DeleteError`
        """
        delete_resp = await self.make_request(
            'DELETE',
            self._webdav_url_ + path.full_path,
            expects=(204,),
            throws=exceptions.DeleteError,
            auth=self._auth,
            connector=self.connector(),
        )
        await delete_resp.release()
        return

    async def metadata(self, path, **kwargs):
        """Queries the remote host for metadata and returns metadata objects based on the return
        value.

        :param waterbutler.core.path.WaterButlerPath path: user-supplied path to query
        :raises: `waterbutler.core.exceptions.MetadataError`
        """
        if path.is_dir:
            return (await self._metadata_folder(path, **kwargs))
        else:
            return (await self._metadata_file(path, **kwargs))

    async def _metadata_file(self, path, **kwargs):
        items = await self._metadata_folder(path, skip_first=False, **kwargs)
        return items[0]

    async def _metadata_folder(self, path, skip_first=True, **kwargs):
        """Performs the actual query against ownCloud. In this case the return code depends on the
        content::

            * 204: Empty response
            * 207: Multipart response
        """
        response = await self.make_request('PROPFIND',
            self._webdav_url_ + path.full_path,
            expects=(204, 207),
            throws=exceptions.MetadataError,
            auth=self._auth,
            connector=self.connector(),
        )

        items = []
        if response.status == 207:
            content = await response.content.read()
            items = await utils.parse_dav_response(content, self.folder, skip_first)
        await response.release()
        return items

    async def create_folder(self, path, **kwargs):
        """Create a folder in the current provider at ``path``. Returns an
        `.metadata.OwnCloudFolderMetadata` object if successful.

        :param waterbutler.core.path.WaterButlerPath path: user-supplied directory path to create
        :param boolean precheck_folder: flag to check for folder before attempting create
        :rtype: `.metadata.OwnCloudFolderMetadata`
        :raises: `waterbutler.core.exceptions.CreateFolderError`
        """
        resp = await self.make_request(
            'MKCOL',
            self._webdav_url_ + path.full_path,
            expects=(201, 405),
            throws=exceptions.CreateFolderError,
            auth=self._auth,
            connector=self.connector()
        )
        await resp.release()
        if resp.status == 405:
            raise exceptions.FolderNamingConflict(path.name)
        # get the folder metadata
        meta = await self.metadata(path.parent)
        return [m for m in meta if m.path == path.materialized_path][0]

    def can_duplicate_names(self):
        return True

    def can_intra_copy(self, dest_provider, path=None):
        return self == dest_provider

    def can_intra_move(self, dest_provider, path=None):
        return self == dest_provider

    async def intra_copy(self, dest_provider, src_path, dest_path):
        return await self._do_dav_move_copy(src_path, dest_path, 'COPY')

    async def intra_move(self, dest_provider, src_path, dest_path):
        return await self._do_dav_move_copy(src_path, dest_path, 'MOVE')

    async def _do_dav_move_copy(self, src_path, dest_path, operation):
        """Performs a quick copy or move operation on the remote host.

        :param waterbutler.core.path.WaterButlerPath src_path: path for the source object
        :param waterbutler.core.path.WaterButlerPath dest_path: path for the destination object
        :param str operation: Either `COPY` or `MOVE`
        :rtype: `.metadata.OwnCloudFileMetadata`
        :rtype: `.metadata.OwnCloudFolderMetadata`
        :raises: `waterbutler.core.exceptions.IntraCopyError`
        """
        if operation != 'MOVE' and operation != 'COPY':
            raise NotImplementedError("ownCloud move/copy only supports MOVE and COPY endpoints")

        resp = await self.make_request(
            operation,
            self._webdav_url_ + src_path.full_path,
            expects=(201, 204),  # WebDAV MOVE/COPY: 201 = Created, 204 = Updated existing
            throws=exceptions.IntraCopyError,
            auth=self._auth,
            connector=self.connector(),
            headers={'Destination': '/remote.php/webdav' + dest_path.full_path}
        )
        await resp.release()

        file_meta = await self.metadata(dest_path)
        if dest_path.is_folder:
            parent_meta = await self.metadata(dest_path.parent)
            meta = [m for m in parent_meta if m.materialized_path == dest_path.materialized_path][0]
            meta.children = file_meta
        else:
            meta = file_meta

        return meta, resp.status == 201

    async def revisions(self, path, **kwargs):
        metadata = await self.metadata(path)
        return [OwnCloudFileRevisionMetadata.from_metadata(metadata)]