Merge lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend into lp:~duplicity-team/duplicity/0.7-series

Proposed by Michael Stapelberg
Status: Merged
Merged at revision: 1044
Proposed branch: lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend
Merge into: lp:~duplicity-team/duplicity/0.7-series
Diff against target: 376 lines (+344/-0)
3 files modified
bin/duplicity.1 (+13/-0)
duplicity/backends/onedrivebackend.py (+330/-0)
duplicity/commandline.py (+1/-0)
To merge this branch: bzr merge lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend
Reviewer Review Type Date Requested Status
duplicity-team Pending
Review via email: mp+245534@code.launchpad.net

Commit message

Add a Microsoft OneDrive backend

Description of the change

Add a Microsoft OneDrive backend

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bin/duplicity.1'
2--- bin/duplicity.1 2014-12-10 19:09:28 +0000
3+++ bin/duplicity.1 2015-01-04 21:11:21 +0000
4@@ -91,6 +91,13 @@
5 .B Python library for mega API
6 - https://github.com/ckornacker/mega.py, ubuntu ppa - ppa:ckornacker/backup
7 .TP
8+.BR "OneDrive backend" " (Microsoft OneDrive)"
9+.B python-requests
10+- http://python-requests.org
11+.br
12+.B python-requests-oauthlib
13+- https://github.com/requests/requests-oauthlib
14+.TP
15 .BR "ncftp backend" " (ftp, select via ncftp+ftp://)"
16 .B NcFTP
17 - http://www.ncftp.com/
18@@ -1178,6 +1185,12 @@
19 mega://user[:password]@mega.co.nz/some_dir
20 .RE
21 .PP
22+.B "OneDrive Backend"
23+.PP
24+.RS
25+onedrive://some_dir
26+.RE
27+.PP
28 .B "Par2 Wrapper Backend"
29 .PP
30 .RS
31
32=== added file 'duplicity/backends/onedrivebackend.py'
33--- duplicity/backends/onedrivebackend.py 1970-01-01 00:00:00 +0000
34+++ duplicity/backends/onedrivebackend.py 2015-01-04 21:11:21 +0000
35@@ -0,0 +1,330 @@
36+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
37+# vim:tabstop=4:shiftwidth=4:expandtab
38+#
39+# Copyright 2014 Google Inc.
40+# Contact Michael Stapelberg <stapelberg+duplicity@google.com>
41+# This is NOT a Google product.
42+#
43+# This file is part of duplicity.
44+#
45+# Duplicity is free software; you can redistribute it and/or modify it
46+# under the terms of the GNU General Public License as published by the
47+# Free Software Foundation; either version 2 of the License, or (at your
48+# option) any later version.
49+#
50+# Duplicity is distributed in the hope that it will be useful, but
51+# WITHOUT ANY WARRANTY; without even the implied warranty of
52+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
53+# General Public License for more details.
54+#
55+# You should have received a copy of the GNU General Public License
56+# along with duplicity; if not, write to the Free Software Foundation,
57+# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
58+
59+import time
60+import json
61+import os
62+import sys
63+# On debian (and derivatives), get these dependencies using:
64+# apt-get install python-requests python-requests-oauthlib
65+# On fedora (and derivatives), get these dependencies using:
66+# yum install python-requests python-requests-oauthlib
67+import requests
68+from requests_oauthlib import OAuth2Session
69+
70+import duplicity.backend
71+from duplicity.errors import BackendException
72+from duplicity import globals
73+from duplicity import log
74+
75+# For documentation on the API, see
76+# http://msdn.microsoft.com/en-us/library/dn659752.aspx
77+# http://msdn.microsoft.com/en-us/library/dn631844.aspx
78+# https://gist.github.com/rgregg/37ba8929768a62131e85
79+class OneDriveBackend(duplicity.backend.Backend):
80+ """Uses Microsoft OneDrive (formerly SkyDrive) for backups."""
81+
82+ OAUTH_TOKEN_PATH = os.path.expanduser(
83+ '~/.duplicity_onedrive_oauthtoken.json')
84+
85+ API_URI = 'https://apis.live.net/v5.0/'
86+ MAXIMUM_FRAGMENT_SIZE = 60 * 1024 * 1024
87+ BITS_1_5_UPLOAD_PROTOCOL = '{7df0354d-249b-430f-820d-3d2a9bef4931}'
88+ CLIENT_ID = '000000004C12E85D'
89+ CLIENT_SECRET = 'k1oR0CbtbvTG9nK1PEDeVW2dzvAaiN4d'
90+ OAUTH_TOKEN_URI = 'https://login.live.com/oauth20_token.srf'
91+ OAUTH_AUTHORIZE_URI = 'https://login.live.com/oauth20_authorize.srf'
92+ OAUTH_REDIRECT_URI = 'https://login.live.com/oauth20_desktop.srf'
93+ # wl.skydrive is for reading files,
94+ # wl.skydrive_update is for creating/writing files,
95+ # wl.offline_access is necessary for duplicity to access onedrive without
96+ # the user being logged in right now.
97+ OAUTH_SCOPE = ['wl.skydrive', 'wl.skydrive_update', 'wl.offline_access']
98+
99+ def __init__(self, parsed_url):
100+ duplicity.backend.Backend.__init__(self, parsed_url)
101+ self.names_to_ids = None
102+ self.user_id = None
103+ self.directory = parsed_url.path.lstrip('/')
104+ if self.directory == "":
105+ raise BackendException((
106+ 'You did not specify a path. '
107+ 'Please specify a path, e.g. onedrive://duplicity_backups'))
108+ if globals.volsize > (10 * 1024 * 1024 * 1024):
109+ raise BackendException((
110+ 'Your --volsize is bigger than 10 GiB, which is the maximum '
111+ 'file size on OneDrive.'))
112+ self.initialize_oauth2_session()
113+ self.resolve_directory()
114+
115+ def initialize_oauth2_session(self):
116+ def token_updater(token):
117+ try:
118+ with open(self.OAUTH_TOKEN_PATH, 'w') as f:
119+ json.dump(token, f)
120+ except Exception as e:
121+ log.Error(('Could not save the OAuth2 token to %s. '
122+ 'This means you may need to do the OAuth2 '
123+ 'authorization process again soon. '
124+ 'Original error: %s' % (
125+ self.OAUTH_TOKEN_PATH, e)))
126+
127+ token = None
128+ try:
129+ with open(self.OAUTH_TOKEN_PATH) as f:
130+ token = json.load(f)
131+ except IOError as e:
132+ log.Error(('Could not load OAuth2 token. '
133+ 'Trying to create a new one. (original error: %s)' % e))
134+
135+ self.http_client = OAuth2Session(
136+ self.CLIENT_ID,
137+ scope=self.OAUTH_SCOPE,
138+ redirect_uri=self.OAUTH_REDIRECT_URI,
139+ token=token,
140+ auto_refresh_kwargs={
141+ 'client_id': self.CLIENT_ID,
142+ 'client_secret': self.CLIENT_SECRET,
143+ },
144+ auto_refresh_url=self.OAUTH_TOKEN_URI,
145+ token_updater=token_updater)
146+
147+ # Send a request to make sure the token is valid (or could at least be
148+ # refreshed successfully, which will happen under the covers). In case
149+ # this request fails, the provided token was too old (i.e. expired),
150+ # and we need to get a new token.
151+ user_info_response = self.http_client.get(self.API_URI + 'me')
152+ if user_info_response.status_code != requests.codes.ok:
153+ token = None
154+
155+ if token is None:
156+ if not sys.stdout.isatty() or not sys.stdin.isatty():
157+ log.FatalError(('The OAuth2 token could not be loaded from %s '
158+ 'and you are not running duplicity '
159+ 'interactively, so duplicity cannot possibly '
160+ 'access OneDrive.' % self.OAUTH_TOKEN_PATH))
161+ authorization_url, state = self.http_client.authorization_url(
162+ self.OAUTH_AUTHORIZE_URI, display='touch')
163+
164+ print ''
165+ print ('In order to authorize duplicity to access your OneDrive, '
166+ 'please open %s in a browser and copy the URL of the blank '
167+ 'page the dialog leads to.' % authorization_url)
168+ print ''
169+
170+ redirected_to = raw_input('URL of the blank page: ')
171+
172+ token = self.http_client.fetch_token(self.OAUTH_TOKEN_URI,
173+ client_secret=self.CLIENT_SECRET,
174+ authorization_response=redirected_to)
175+
176+ user_info_response = self.http_client.get(self.API_URI + 'me')
177+ user_info_response.raise_for_status()
178+
179+ try:
180+ with open(self.OAUTH_TOKEN_PATH, 'w') as f:
181+ json.dump(token, f)
182+ except Exception as e:
183+ log.Error(('Could not save the OAuth2 token to %s. '
184+ 'This means you need to do the OAuth2 authorization '
185+ 'process on every start of duplicity. '
186+ 'Original error: %s' % (
187+ self.OAUTH_TOKEN_PATH, e)))
188+
189+ if not 'id' in user_info_response.json():
190+ log.Error('user info response lacks the "id" field.')
191+
192+ self.user_id = user_info_response.json()['id']
193+
194+ def resolve_directory(self):
195+ """Ensures self.directory_id contains the folder id for the path.
196+
197+ There is no API call to resolve a logical path (e.g.
198+ /backups/duplicity/notebook/), so we recursively list directories
199+ until we get the object id of the configured directory, creating
200+ directories as necessary.
201+ """
202+ object_id = 'me/skydrive'
203+ for component in self.directory.split('/'):
204+ tried_mkdir = False
205+ while True:
206+ files = self.get_files(object_id)
207+ names_to_ids = {x['name']: x['id'] for x in files}
208+ if component not in names_to_ids:
209+ if not tried_mkdir:
210+ self.mkdir(object_id, component)
211+ tried_mkdir = True
212+ continue
213+ raise BackendException((
214+ 'Could not resolve/create directory "%s" on '
215+ 'OneDrive: %s not in %s (files of folder %s)' % (
216+ self.directory, component,
217+ names_to_ids.keys(), object_id)))
218+ break
219+ object_id = names_to_ids[component]
220+ self.directory_id = object_id
221+ log.Debug('OneDrive id for the configured directory "%s" is "%s"' % (
222+ self.directory, self.directory_id))
223+
224+ def mkdir(self, object_id, folder_name):
225+ data = {'name': folder_name, 'description': 'Created by duplicity'}
226+ headers = {'Content-Type': 'application/json'}
227+ response = self.http_client.post(
228+ self.API_URI + object_id,
229+ data=json.dumps(data),
230+ headers=headers)
231+ response.raise_for_status()
232+
233+ def get_files(self, path):
234+ response = self.http_client.get(self.API_URI + path + '/files')
235+ response.raise_for_status()
236+ if 'data' not in response.json():
237+ raise BackendException((
238+ 'Malformed JSON: expected "data" member in %s' % (
239+ response.json())))
240+ return response.json()['data']
241+
242+ def _list(self):
243+ files = self.get_files(self.directory_id)
244+ self.names_to_ids = {x['name']: x['id'] for x in files}
245+ return [x['name'] for x in files]
246+
247+ def get_file_id(self, remote_filename):
248+ """Returns the file id from cache, updating the cache if necessary."""
249+ if (self.names_to_ids is None or
250+ remote_filename not in self.names_to_ids):
251+ self._list()
252+ return self.names_to_ids.get(remote_filename)
253+
254+ def _get(self, remote_filename, local_path):
255+ with local_path.open('wb') as f:
256+ file_id = self.get_file_id(remote_filename)
257+ if file_id is None:
258+ raise BackendException((
259+ 'File "%s" cannot be downloaded: it does not exist' % (
260+ remote_filename)))
261+ response = self.http_client.get(
262+ self.API_URI + file_id + '/content', stream=True)
263+ response.raise_for_status()
264+ for chunk in response.iter_content(chunk_size=4096):
265+ if chunk:
266+ f.write(chunk)
267+ f.flush()
268+
269+ def _put(self, source_path, remote_filename):
270+ # Check if the user has enough space available on OneDrive before even
271+ # attempting to upload the file.
272+ source_size = os.path.getsize(source_path.name)
273+ start = time.time()
274+ response = self.http_client.get(self.API_URI + 'me/skydrive/quota')
275+ response.raise_for_status()
276+ if ('available' in response.json() and
277+ source_size > response.json()['available']):
278+ raise BackendException((
279+ 'Out of space: trying to store "%s" (%d bytes), but only '
280+ '%d bytes available on OneDrive.' % (
281+ source_path.name, source_size,
282+ response.json()['available'])))
283+ log.Debug("Checked quota in %fs" % (time.time() - start))
284+
285+ with source_path.open() as source_file:
286+ start = time.time()
287+ # Create a BITS session, so that we can upload large files.
288+ short_directory_id = self.directory_id.split('.')[-1]
289+ url = 'https://cid-%s.users.storage.live.com/items/%s/%s' % (
290+ self.user_id, short_directory_id, remote_filename)
291+ headers = {
292+ 'X-Http-Method-Override': 'BITS_POST',
293+ 'BITS-Packet-Type': 'Create-Session',
294+ 'BITS-Supported-Protocols': self.BITS_1_5_UPLOAD_PROTOCOL,
295+ }
296+
297+ response = self.http_client.post(
298+ url,
299+ headers=headers)
300+ response.raise_for_status()
301+ if (not 'bits-packet-type' in response.headers or
302+ response.headers['bits-packet-type'].lower() != 'ack'):
303+ raise BackendException((
304+ 'File "%s" cannot be uploaded: '
305+ 'Could not create BITS session: '
306+ 'Server response did not include BITS-Packet-Type: ACK' % (
307+ remote_filename)))
308+ bits_session_id = response.headers['bits-session-id']
309+ log.Debug('BITS session id is "%s"' % bits_session_id)
310+
311+ # Send fragments (with a maximum size of 60 MB each).
312+ offset = 0
313+ while True:
314+ chunk = source_file.read(self.MAXIMUM_FRAGMENT_SIZE)
315+ if len(chunk) == 0:
316+ break
317+ headers = {
318+ 'X-Http-Method-Override': 'BITS_POST',
319+ 'BITS-Packet-Type': 'Fragment',
320+ 'BITS-Session-Id': bits_session_id,
321+ 'Content-Range': 'bytes %d-%d/%d' % (offset, offset + len(chunk) - 1, source_size),
322+ }
323+ response = self.http_client.post(
324+ url,
325+ headers=headers,
326+ data=chunk)
327+ response.raise_for_status()
328+ offset += len(chunk)
329+
330+ # Close the BITS session to commit the file.
331+ headers = {
332+ 'X-Http-Method-Override': 'BITS_POST',
333+ 'BITS-Packet-Type': 'Close-Session',
334+ 'BITS-Session-Id': bits_session_id,
335+ }
336+ response = self.http_client.post(url, headers=headers)
337+ response.raise_for_status()
338+
339+ log.Debug("PUT file in %fs" % (time.time() - start))
340+
341+ def _delete(self, remote_filename):
342+ file_id = self.get_file_id(remote_filename)
343+ if file_id is None:
344+ raise BackendException((
345+ 'File "%s" cannot be deleted: it does not exist' % (
346+ remote_filename)))
347+ response = self.http_client.delete(self.API_URI + file_id)
348+ response.raise_for_status()
349+
350+ def _query(self, remote_filename):
351+ file_id = self.get_file_id(remote_filename)
352+ if file_id is None:
353+ return {'size': -1}
354+ response = self.http_client.get(self.API_URI + file_id)
355+ response.raise_for_status()
356+ if 'size' not in response.json():
357+ raise BackendException((
358+ 'Malformed JSON: expected "size" member in %s' % (
359+ response.json())))
360+ return {'size': response.json()['size']}
361+
362+ def _retry_cleanup(self):
363+ self.initialize_oauth2_session()
364+
365+duplicity.backend.register_backend('onedrive', OneDriveBackend)
366
367=== modified file 'duplicity/commandline.py'
368--- duplicity/commandline.py 2014-12-12 14:54:56 +0000
369+++ duplicity/commandline.py 2015-01-04 21:11:21 +0000
370@@ -859,6 +859,7 @@
371 mega://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s
372 copy://%(user)s[:%(password)s]@%(other_host)s/%(some_dir)s
373 dpbx:///%(some_dir)s
374+ onedrive://%(some_dir)s
375
376 """ % dict
377

Subscribers

People subscribed via source and target branches