Merge lp:~stapelberg+ubuntu/duplicity/add-onedrive-backend into lp:~duplicity-team/duplicity/0.7-series
- add-onedrive-backend
- Merge into 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 |
Related bugs: |
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 |