Skip to content

Commit b7993b3

Browse files
committed
remoteStorage implementation
1 parent e7275ab commit b7993b3

File tree

3 files changed

+299
-0
lines changed

3 files changed

+299
-0
lines changed

docs/config.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,28 @@ These storages generally support reading and changing of their items. Their
130130
default value for ``read_only`` is ``false``, but can be set to ``true`` if
131131
wished.
132132

133+
CalDAV and CardDAV
134+
++++++++++++++++++
135+
133136
.. autostorage:: vdirsyncer.storage.dav.CaldavStorage
134137

135138
.. autostorage:: vdirsyncer.storage.dav.CarddavStorage
136139

140+
remoteStorage
141+
+++++++++++++
142+
143+
.. autostorage:: vdirsyncer.storage.remotestorage.RemoteStorageContacts
144+
145+
.. autostorage:: vdirsyncer.storage.remotestorage.RemoteStorageCalendars
146+
147+
Local
148+
+++++
149+
137150
.. autostorage:: vdirsyncer.storage.filesystem.FilesystemStorage
138151

139152
.. autostorage:: vdirsyncer.storage.singlefile.SingleFileStorage
140153

154+
141155
Read-only storages
142156
~~~~~~~~~~~~~~~~~~
143157

vdirsyncer/cli/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def __init__(self):
4141
filesystem='vdirsyncer.storage.filesystem.FilesystemStorage',
4242
http='vdirsyncer.storage.http.HttpStorage',
4343
singlefile='vdirsyncer.storage.singlefile.SingleFileStorage',
44+
remotestorage_contacts=(
45+
'vdirsyncer.storage.remotestorage.RemoteStorageContacts'),
46+
remotestorage_calendars=(
47+
'vdirsyncer.storage.remotestorage.RemoteStorageCalendars'),
4448
)
4549

4650
def __getitem__(self, name):

vdirsyncer/storage/remotestorage.py

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
'''
2+
A storage type for accessing contact and calendar data from `remoteStorage
3+
<https://remotestorage.io>`_. It is highly experimental.
4+
5+
A few things are hardcoded for now so the user doesn't have to specify those
6+
things, and plugging in an account "just works".
7+
8+
We also use a custom ``data``-URI for the redirect in OAuth:
9+
10+
- There is no server that could be compromised.
11+
- With a proper URL, ``access_token`` would be stored in the browser history.
12+
For some reason Firefox doesn't do that with ``data``-URIs.
13+
- ``data``-URIs have no clear domain name that could prevent from phishing
14+
attacks. However, I don't see a way to phish without compromising the
15+
vdirsyncer installation, at which point any hope would already be lost.
16+
- On the downside, redirect URIs are monstrous.
17+
18+
'''
19+
20+
import click
21+
22+
from .base import Item, Storage
23+
from .http import HTTP_STORAGE_PARAMETERS, USERAGENT, prepare_client_cert, \
24+
prepare_verify
25+
from .. import exceptions, log, utils
26+
27+
CLIENT_ID = USERAGENT
28+
DRAFT_VERSION = '05'
29+
30+
logger = log.get(__name__)
31+
32+
urljoin = utils.compat.urlparse.urljoin
33+
34+
35+
def _ensure_slash(dir):
36+
return dir.rstrip('/') + '/'
37+
38+
39+
def _iter_listing(json):
40+
new_listing = '@context' in json # draft-02 and beyond
41+
if new_listing:
42+
json = json['items']
43+
for name, info in utils.compat.iteritems(json):
44+
if not new_listing:
45+
info = {'ETag': info}
46+
yield name, info
47+
48+
49+
class Session(object):
50+
51+
def __init__(self, account, scope, verify=True, verify_fingerprint=None,
52+
auth_cert=None, access_token=None, collection=None):
53+
from oauthlib.oauth2 import MobileApplicationClient
54+
from requests_oauthlib import OAuth2Session
55+
56+
self.user, self.host = account.split('@')
57+
58+
self._settings = {
59+
'cert': prepare_client_cert(auth_cert)
60+
}
61+
self._settings.update(prepare_verify(verify, verify_fingerprint))
62+
63+
self.scope = scope + ':rw'
64+
if collection:
65+
scope = urljoin(_ensure_slash(scope),
66+
_ensure_slash(collection))
67+
68+
self._session = OAuth2Session(
69+
CLIENT_ID, client=MobileApplicationClient(CLIENT_ID),
70+
scope=self.scope,
71+
redirect_uri=('data:text/html,<script>'
72+
'document.write(location.hash);</script>'),
73+
token={'access_token': access_token},
74+
)
75+
self._discover_endpoints(scope)
76+
77+
if not access_token:
78+
self._get_access_token()
79+
80+
def request(self, method, path, **kwargs):
81+
url = self.endpoints['storage']
82+
if path:
83+
url = urljoin(url, path)
84+
85+
settings = dict(self._settings)
86+
settings.update(kwargs)
87+
88+
return utils.http.request(method, url,
89+
session=self._session, **settings)
90+
91+
def _get_access_token(self):
92+
authorization_url, state = \
93+
self._session.authorization_url(self.endpoints['oauth'])
94+
95+
click.echo('Go to {}'.format(authorization_url))
96+
fragment = click.prompt('What is on the webpage?')
97+
98+
# We're using a data-URI as redirect, which is not HTTPS and therefore,
99+
# according to oauthlib, insecure.
100+
# This is a workaround for https://github.com/idan/oauthlib/issues/392
101+
self._session.token_from_fragment('https://example.com/' + fragment)
102+
click.echo('Paste this into your storage configuration:\n'
103+
'access_token = "{}"\n'
104+
'Aborting synchronization.'
105+
.format(self._session.token['access_token']))
106+
raise exceptions.UserError('Aborted!')
107+
108+
def _discover_endpoints(self, subpath):
109+
r = utils.http.request(
110+
'GET', 'https://{host}/.well-known/webfinger?resource=acct:{user}'
111+
.format(host=self.host, user=self.user),
112+
**self._settings
113+
)
114+
j = r.json()
115+
for link in j['links']:
116+
if 'draft-dejong-remotestorage' in link['rel']:
117+
break
118+
119+
storage = urljoin(_ensure_slash(link['href']),
120+
_ensure_slash(subpath))
121+
props = link['properties']
122+
oauth = props['http://tools.ietf.org/html/rfc6749#section-4.2']
123+
self.endpoints = dict(storage=storage, oauth=oauth)
124+
125+
126+
class RemoteStorage(Storage):
127+
__doc__ = '''
128+
:param account: remoteStorage account, ``"[email protected]"``.
129+
''' + HTTP_STORAGE_PARAMETERS + '''
130+
'''
131+
132+
storage_name = None
133+
item_mimetype = None
134+
fileext = None
135+
136+
def __init__(self, account, verify=True, verify_fingerprint=None,
137+
auth_cert=None, access_token=None, **kwargs):
138+
super(RemoteStorage, self).__init__(**kwargs)
139+
self.session = Session(
140+
account=account,
141+
verify=verify,
142+
verify_fingerprint=verify_fingerprint,
143+
auth_cert=auth_cert,
144+
access_token=access_token,
145+
collection=self.collection,
146+
scope=self.scope)
147+
148+
@classmethod
149+
def discover(cls, **base_args):
150+
if base_args.pop('collection', None) is not None:
151+
raise TypeError('collection argument must not be given.')
152+
153+
session_args, _ = utils.split_dict(base_args, lambda key: key in (
154+
'account', 'verify', 'auth', 'verify_fingerprint', 'auth_cert',
155+
'access_token'
156+
))
157+
158+
session = Session(scope=cls.scope, **session_args)
159+
160+
try:
161+
r = session.request('GET', '')
162+
except exceptions.NotFoundError:
163+
return
164+
165+
for name, info in _iter_listing(r.json()):
166+
if not name.endswith('/'):
167+
continue # not a folder
168+
169+
newargs = dict(base_args)
170+
newargs['collection'] = name.rstrip('/')
171+
yield newargs
172+
173+
@classmethod
174+
def create_collection(cls, collection, **kwargs):
175+
# remoteStorage folders are autocreated.
176+
assert collection
177+
assert '/' not in collection
178+
kwargs['collection'] = collection
179+
return kwargs
180+
181+
def list(self):
182+
try:
183+
r = self.session.request('GET', '')
184+
except exceptions.NotFoundError:
185+
return
186+
187+
for name, info in _iter_listing(r.json()):
188+
if not name.endswith(self.fileext):
189+
continue
190+
191+
etag = info['ETag']
192+
etag = '"' + etag + '"'
193+
yield name, etag
194+
195+
def _put(self, href, item, etag):
196+
headers = {'Content-Type': self.item_mimetype + '; charset=UTF-8'}
197+
if etag is None:
198+
headers['If-None-Match'] = '*'
199+
else:
200+
headers['If-Match'] = etag
201+
202+
response = self.session.request(
203+
'PUT',
204+
href,
205+
data=item.raw.encode('utf-8'),
206+
headers=headers
207+
)
208+
if not response.url.endswith('/' + href):
209+
raise exceptions.InvalidResponse('spec doesn\'t allow redirects')
210+
return href, response.headers['etag']
211+
212+
def update(self, href, item, etag):
213+
assert etag
214+
href, etag = self._put(href, item, etag)
215+
return etag
216+
217+
def upload(self, item):
218+
href = utils.generate_href(item.ident)
219+
href = utils.compat.urlquote(href, '@') + self.fileext
220+
return self._put(href, item, None)
221+
222+
def delete(self, href, etag):
223+
headers = {'If-Match': etag}
224+
self.session.request('DELETE', href, headers=headers)
225+
226+
def get(self, href):
227+
response = self.session.request('GET', href)
228+
return Item(response.text), response.headers['etag']
229+
230+
def get_meta(self, key):
231+
try:
232+
return self.session.request('GET', key).text or None
233+
except exceptions.NotFoundError:
234+
pass
235+
236+
def set_meta(self, key, value):
237+
self.session.request(
238+
'PUT',
239+
key,
240+
data=(value or u'').encode('utf-8'),
241+
headers={'Content-Type': 'text/plain; charset=utf-8'}
242+
)
243+
244+
245+
class RemoteStorageContacts(RemoteStorage):
246+
__doc__ = '''
247+
remoteStorage contacts. Uses the `vdir_contacts` scope.
248+
''' + RemoteStorage.__doc__
249+
250+
storage_name = 'remotestorage_contacts'
251+
fileext = '.vcf'
252+
item_mimetype = 'text/vcard'
253+
scope = 'vdir_contacts'
254+
255+
def __init__(self, **kwargs):
256+
if kwargs.get('collection'):
257+
raise ValueError(
258+
'No collections allowed for contacts, '
259+
'there is only one addressbook. '
260+
'Use the vcard groups construct to categorize your contacts '
261+
'into groups.'
262+
)
263+
264+
super(RemoteStorageContacts, self).__init__(**kwargs)
265+
266+
267+
class RemoteStorageCalendars(RemoteStorage):
268+
__doc__ = '''
269+
remoteStorage calendars. Uses the `vdir_calendars` scope.
270+
''' + RemoteStorage.__doc__
271+
272+
storage_name = 'remotestorage_calendars'
273+
fileext = '.ics'
274+
item_mimetype = 'text/icalendar'
275+
scope = 'vdir_calendars'
276+
277+
def __init__(self, **kwargs):
278+
if not kwargs.get('collection'):
279+
raise ValueError('The collections parameter is required.')
280+
281+
super(RemoteStorageCalendars, self).__init__(**kwargs)

0 commit comments

Comments
 (0)