← Back to team overview

duplicity-team team mailing list archive

[Merge] lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend into lp:duplicity

 

Michael Stapelberg has proposed merging lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend into lp:duplicity.

Commit message:
Add a Microsoft OneDrive backend

Requested reviews:
  duplicity-team (duplicity-team)

For more details, see:
https://code.launchpad.net/~stapelberg+ubuntu/duplicity/add-onedrive-backend/+merge/245534

Add a Microsoft OneDrive backend
-- 
Your team duplicity-team is requested to review the proposed merge of lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend into lp:duplicity.
=== modified file 'bin/duplicity.1'
--- bin/duplicity.1	2014-12-10 19:09:28 +0000
+++ bin/duplicity.1	2015-01-04 21:06:25 +0000
@@ -91,6 +91,13 @@
 .B Python library for mega API
 - https://github.com/ckornacker/mega.py, ubuntu ppa - ppa:ckornacker/backup
 .TP
+.BR "OneDrive backend" " (Microsoft OneDrive)"
+.B python-requests
+- http://python-requests.org
+.br
+.B python-requests-oauthlib
+- https://github.com/requests/requests-oauthlib
+.TP
 .BR "ncftp backend" " (ftp, select via ncftp+ftp://)"
 .B NcFTP
 - http://www.ncftp.com/
@@ -1178,6 +1185,12 @@
 mega://user[:password]@mega.co.nz/some_dir
 .RE
 .PP
+.B "OneDrive Backend"
+.PP
+.RS
+onedrive://some_dir
+.RE
+.PP
 .B "Par2 Wrapper Backend"
 .PP
 .RS

=== added file 'duplicity/backends/onedrivebackend.py'
--- duplicity/backends/onedrivebackend.py	1970-01-01 00:00:00 +0000
+++ duplicity/backends/onedrivebackend.py	2015-01-04 21:06:25 +0000
@@ -0,0 +1,330 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+# vim:tabstop=4:shiftwidth=4:expandtab
+#
+# Copyright 2014 Google Inc.
+# Contact Michael Stapelberg <stapelberg+duplicity@xxxxxxxxxx>
+# This is NOT a Google product.
+#
+# This file is part of duplicity.
+#
+# Duplicity is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the
+# Free Software Foundation; either version 2 of the License, or (at your
+# option) any later version.
+#
+# Duplicity 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 duplicity; if not, write to the Free Software Foundation,
+# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+import time
+import json
+import os
+import sys
+# On debian (and derivatives), get these dependencies using:
+# apt-get install python-requests python-requests-oauthlib
+# On fedora (and derivatives), get these dependencies using:
+# yum install python-requests python-requests-oauthlib
+import requests
+from requests_oauthlib import OAuth2Session
+
+import duplicity.backend
+from duplicity.errors import BackendException
+from duplicity import globals
+from duplicity import log
+
+# For documentation on the API, see
+# http://msdn.microsoft.com/en-us/library/dn659752.aspx
+# http://msdn.microsoft.com/en-us/library/dn631844.aspx
+# https://gist.github.com/rgregg/37ba8929768a62131e85
+class OneDriveBackend(duplicity.backend.Backend):
+    """Uses Microsoft OneDrive (formerly SkyDrive) for backups."""
+
+    OAUTH_TOKEN_PATH = os.path.expanduser(
+        '~/.duplicity_onedrive_oauthtoken.json')
+
+    API_URI = 'https://apis.live.net/v5.0/'
+    MAXIMUM_FRAGMENT_SIZE = 60 * 1024 * 1024
+    BITS_1_5_UPLOAD_PROTOCOL = '{7df0354d-249b-430f-820d-3d2a9bef4931}'
+    CLIENT_ID = '000000004C12E85D'
+    CLIENT_SECRET = 'k1oR0CbtbvTG9nK1PEDeVW2dzvAaiN4d'
+    OAUTH_TOKEN_URI = 'https://login.live.com/oauth20_token.srf'
+    OAUTH_AUTHORIZE_URI = 'https://login.live.com/oauth20_authorize.srf'
+    OAUTH_REDIRECT_URI = 'https://login.live.com/oauth20_desktop.srf'
+    # wl.skydrive is for reading files,
+    # wl.skydrive_update is for creating/writing files,
+    # wl.offline_access is necessary for duplicity to access onedrive without
+    # the user being logged in right now.
+    OAUTH_SCOPE = ['wl.skydrive', 'wl.skydrive_update', 'wl.offline_access']
+
+    def __init__(self, parsed_url):
+        duplicity.backend.Backend.__init__(self, parsed_url)
+        self.names_to_ids = None
+        self.user_id = None
+        self.directory = parsed_url.path.lstrip('/')
+        if self.directory == "":
+            raise BackendException((
+                'You did not specify a path. '
+                'Please specify a path, e.g. onedrive://duplicity_backups'))
+        if globals.volsize > (10 * 1024 * 1024 * 1024):
+            raise BackendException((
+                'Your --volsize is bigger than 10 GiB, which is the maximum '
+                'file size on OneDrive.'))
+        self.initialize_oauth2_session()
+        self.resolve_directory()
+
+    def initialize_oauth2_session(self):
+        def token_updater(token):
+            try:
+                with open(self.OAUTH_TOKEN_PATH, 'w') as f:
+                    json.dump(token, f)
+            except Exception as e:
+                log.Error(('Could not save the OAuth2 token to %s. '
+                           'This means you may need to do the OAuth2 '
+                           'authorization process again soon. '
+                           'Original error: %s' % (
+                               self.OAUTH_TOKEN_PATH, e)))
+
+        token = None
+        try:
+            with open(self.OAUTH_TOKEN_PATH) as f:
+                token = json.load(f)
+        except IOError as e:
+            log.Error(('Could not load OAuth2 token. '
+                       'Trying to create a new one. (original error: %s)' % e))
+
+        self.http_client = OAuth2Session(
+                self.CLIENT_ID,
+                scope=self.OAUTH_SCOPE,
+                redirect_uri=self.OAUTH_REDIRECT_URI,
+                token=token,
+                auto_refresh_kwargs={
+                    'client_id': self.CLIENT_ID,
+                    'client_secret': self.CLIENT_SECRET,
+                },
+                auto_refresh_url=self.OAUTH_TOKEN_URI,
+                token_updater=token_updater)
+
+        # Send a request to make sure the token is valid (or could at least be
+        # refreshed successfully, which will happen under the covers). In case
+        # this request fails, the provided token was too old (i.e. expired),
+        # and we need to get a new token.
+        user_info_response = self.http_client.get(self.API_URI + 'me')
+        if user_info_response.status_code != requests.codes.ok:
+            token = None
+
+        if token is None:
+            if not sys.stdout.isatty() or not sys.stdin.isatty():
+                log.FatalError(('The OAuth2 token could not be loaded from %s '
+                                'and you are not running duplicity '
+                                'interactively, so duplicity cannot possibly '
+                                'access OneDrive.' % self.OAUTH_TOKEN_PATH))
+            authorization_url, state = self.http_client.authorization_url(
+                    self.OAUTH_AUTHORIZE_URI, display='touch')
+
+            print ''
+            print ('In order to authorize duplicity to access your OneDrive, '
+                   'please open %s in a browser and copy the URL of the blank '
+                   'page the dialog leads to.' % authorization_url)
+            print ''
+
+            redirected_to = raw_input('URL of the blank page: ')
+
+            token = self.http_client.fetch_token(self.OAUTH_TOKEN_URI,
+                client_secret=self.CLIENT_SECRET,
+                authorization_response=redirected_to)
+
+            user_info_response = self.http_client.get(self.API_URI + 'me')
+            user_info_response.raise_for_status()
+
+            try:
+                with open(self.OAUTH_TOKEN_PATH, 'w') as f:
+                    json.dump(token, f)
+            except Exception as e:
+                log.Error(('Could not save the OAuth2 token to %s. '
+                           'This means you need to do the OAuth2 authorization '
+                           'process on every start of duplicity. '
+                           'Original error: %s' % (
+                               self.OAUTH_TOKEN_PATH, e)))
+
+        if not 'id' in user_info_response.json():
+            log.Error('user info response lacks the "id" field.')
+
+        self.user_id = user_info_response.json()['id']
+
+    def resolve_directory(self):
+        """Ensures self.directory_id contains the folder id for the path.
+        
+        There is no API call to resolve a logical path (e.g.
+        /backups/duplicity/notebook/), so we recursively list directories
+        until we get the object id of the configured directory, creating
+        directories as necessary.
+        """
+        object_id = 'me/skydrive'
+        for component in self.directory.split('/'):
+            tried_mkdir = False
+            while True:
+                files = self.get_files(object_id)
+                names_to_ids = {x['name']: x['id'] for x in files}
+                if component not in names_to_ids:
+                    if not tried_mkdir:
+                        self.mkdir(object_id, component)
+                        tried_mkdir = True
+                        continue
+                    raise BackendException((
+                            'Could not resolve/create directory "%s" on '
+                            'OneDrive: %s not in %s (files of folder %s)' % (
+                                self.directory, component,
+                                names_to_ids.keys(), object_id)))
+                break
+            object_id = names_to_ids[component]
+        self.directory_id = object_id
+        log.Debug('OneDrive id for the configured directory "%s" is "%s"' % (
+            self.directory, self.directory_id))
+
+    def mkdir(self, object_id, folder_name):
+        data = {'name': folder_name, 'description': 'Created by duplicity'}
+        headers = {'Content-Type': 'application/json'}
+        response = self.http_client.post(
+            self.API_URI + object_id,
+            data=json.dumps(data),
+            headers=headers)
+        response.raise_for_status()
+
+    def get_files(self, path):
+        response = self.http_client.get(self.API_URI + path + '/files')
+        response.raise_for_status()
+        if 'data' not in response.json():
+            raise BackendException((
+                'Malformed JSON: expected "data" member in %s' % (
+                    response.json())))
+        return response.json()['data']
+
+    def _list(self):
+        files = self.get_files(self.directory_id)
+        self.names_to_ids = {x['name']: x['id'] for x in files}
+        return [x['name'] for x in files]
+
+    def get_file_id(self, remote_filename):
+        """Returns the file id from cache, updating the cache if necessary."""
+        if (self.names_to_ids is None or
+            remote_filename not in self.names_to_ids):
+            self._list()
+        return self.names_to_ids.get(remote_filename)
+
+    def _get(self, remote_filename, local_path):
+        with local_path.open('wb') as f:
+            file_id = self.get_file_id(remote_filename)
+            if file_id is None:
+                raise BackendException((
+                    'File "%s" cannot be downloaded: it does not exist' % (
+                        remote_filename)))
+            response = self.http_client.get(
+                self.API_URI + file_id + '/content', stream=True)
+            response.raise_for_status()
+            for chunk in response.iter_content(chunk_size=4096):
+                if chunk:
+                    f.write(chunk)
+            f.flush()
+
+    def _put(self, source_path, remote_filename):
+        # Check if the user has enough space available on OneDrive before even
+        # attempting to upload the file.
+        source_size = os.path.getsize(source_path.name)
+        start = time.time()
+        response = self.http_client.get(self.API_URI + 'me/skydrive/quota')
+        response.raise_for_status()
+        if ('available' in response.json() and
+            source_size > response.json()['available']):
+            raise BackendException((
+                'Out of space: trying to store "%s" (%d bytes), but only '
+                '%d bytes available on OneDrive.' % (
+                    source_path.name, source_size,
+                    response.json()['available'])))
+        log.Debug("Checked quota in %fs" % (time.time() - start))
+
+        with source_path.open() as source_file:
+            start = time.time()
+            # Create a BITS session, so that we can upload large files.
+            short_directory_id = self.directory_id.split('.')[-1]
+            url = 'https://cid-%s.users.storage.live.com/items/%s/%s' % (
+                self.user_id, short_directory_id, remote_filename)
+            headers = {
+                'X-Http-Method-Override': 'BITS_POST',
+                'BITS-Packet-Type': 'Create-Session',
+                'BITS-Supported-Protocols': self.BITS_1_5_UPLOAD_PROTOCOL,
+            }
+
+            response = self.http_client.post(
+                url,
+                headers=headers)
+            response.raise_for_status()
+            if (not 'bits-packet-type' in response.headers or
+                response.headers['bits-packet-type'].lower() != 'ack'):
+                raise BackendException((
+                    'File "%s" cannot be uploaded: '
+                    'Could not create BITS session: '
+                    'Server response did not include BITS-Packet-Type: ACK' % (
+                        remote_filename)))
+            bits_session_id = response.headers['bits-session-id']
+            log.Debug('BITS session id is "%s"' % bits_session_id)
+
+            # Send fragments (with a maximum size of 60 MB each).
+            offset = 0
+            while True:
+                chunk = source_file.read(self.MAXIMUM_FRAGMENT_SIZE)
+                if len(chunk) == 0:
+                    break
+                headers = {
+                    'X-Http-Method-Override': 'BITS_POST',
+                    'BITS-Packet-Type': 'Fragment',
+                    'BITS-Session-Id': bits_session_id,
+                    'Content-Range': 'bytes %d-%d/%d' % (offset, offset + len(chunk) - 1, source_size),
+                }
+                response = self.http_client.post(
+                    url,
+                    headers=headers,
+                    data=chunk)
+                response.raise_for_status()
+                offset += len(chunk)
+
+            # Close the BITS session to commit the file.
+            headers = {
+                'X-Http-Method-Override': 'BITS_POST',
+                'BITS-Packet-Type': 'Close-Session',
+                'BITS-Session-Id': bits_session_id,
+            }
+            response = self.http_client.post(url, headers=headers)
+            response.raise_for_status()
+
+            log.Debug("PUT file in %fs" % (time.time() - start))
+
+    def _delete(self, remote_filename):
+        file_id = self.get_file_id(remote_filename)
+        if file_id is None:
+            raise BackendException((
+                'File "%s" cannot be deleted: it does not exist' % (
+                    remote_filename)))
+        response = self.http_client.delete(self.API_URI + file_id)
+        response.raise_for_status()
+
+    def _query(self, remote_filename):
+        file_id = self.get_file_id(remote_filename)
+        if file_id is None:
+            return {'size': -1}
+        response = self.http_client.get(self.API_URI + file_id)
+        response.raise_for_status()
+        if 'size' not in response.json():
+            raise BackendException((
+                'Malformed JSON: expected "size" member in %s' % (
+                    response.json())))
+        return {'size': response.json()['size']}
+
+    def _retry_cleanup(self):
+        self.initialize_oauth2_session()
+
+duplicity.backend.register_backend('onedrive', OneDriveBackend)

=== modified file 'duplicity/commandline.py'
--- duplicity/commandline.py	2014-12-12 14:54:56 +0000
+++ duplicity/commandline.py	2015-01-04 21:06:25 +0000
@@ -859,6 +859,7 @@
   mega://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s
   copy://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s
   dpbx:///%(some_dir)s
+  onedrive://%(some_dir)s
 
 """ % dict
 


Follow ups