Skip to content

Commit b0ca6a1

Browse files
committed
remoteStorage implementation
1 parent 86d35c6 commit b0ca6a1

File tree

3 files changed

+295
-0
lines changed

3 files changed

+295
-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: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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+
self._session.token_from_fragment('https://fuckyou.com/' + fragment)
98+
click.echo('Paste this into your storage configuration:\n'
99+
'access_token = "{}"\n'
100+
'Aborting synchronization.'
101+
.format(self._session.token['access_token']))
102+
raise exceptions.UserError('Aborted!')
103+
104+
def _discover_endpoints(self, subpath):
105+
r = utils.http.request(
106+
'GET', 'https://{host}/.well-known/webfinger?resource=acct:{user}'
107+
.format(host=self.host, user=self.user),
108+
**self._settings
109+
)
110+
j = r.json()
111+
for link in j['links']:
112+
if 'draft-dejong-remotestorage' in link['rel']:
113+
break
114+
115+
storage = urljoin(_ensure_slash(link['href']),
116+
_ensure_slash(subpath))
117+
props = link['properties']
118+
oauth = props['http://tools.ietf.org/html/rfc6749#section-4.2']
119+
self.endpoints = dict(storage=storage, oauth=oauth)
120+
121+
122+
class RemoteStorage(Storage):
123+
__doc__ = '''
124+
:param account: remoteStorage account, ``"[email protected]"``.
125+
''' + HTTP_STORAGE_PARAMETERS + '''
126+
'''
127+
128+
storage_name = None
129+
item_mimetype = None
130+
fileext = None
131+
132+
def __init__(self, account, verify=True, verify_fingerprint=None,
133+
auth_cert=None, access_token=None, **kwargs):
134+
super(RemoteStorage, self).__init__(**kwargs)
135+
self.session = Session(
136+
account=account,
137+
verify=verify,
138+
verify_fingerprint=verify_fingerprint,
139+
auth_cert=auth_cert,
140+
access_token=access_token,
141+
collection=self.collection,
142+
scope=self.scope)
143+
144+
@classmethod
145+
def discover(cls, **base_args):
146+
if base_args.pop('collection', None) is not None:
147+
raise TypeError('collection argument must not be given.')
148+
149+
session_args, _ = utils.split_dict(base_args, lambda key: key in (
150+
'account', 'verify', 'auth', 'verify_fingerprint', 'auth_cert',
151+
'access_token'
152+
))
153+
154+
session = Session(scope=cls.scope, **session_args)
155+
156+
try:
157+
r = session.request('GET', '')
158+
except exceptions.NotFoundError:
159+
return
160+
161+
for name, info in _iter_listing(r.json()):
162+
if not name.endswith('/'):
163+
continue # not a folder
164+
165+
newargs = dict(base_args)
166+
newargs['collection'] = name.rstrip('/')
167+
yield newargs
168+
169+
@classmethod
170+
def create_collection(cls, collection, **kwargs):
171+
# remoteStorage folders are autocreated.
172+
assert collection
173+
assert '/' not in collection
174+
kwargs['collection'] = collection
175+
return kwargs
176+
177+
def list(self):
178+
try:
179+
r = self.session.request('GET', '')
180+
except exceptions.NotFoundError:
181+
return
182+
183+
for name, info in _iter_listing(r.json()):
184+
if not name.endswith(self.fileext):
185+
continue
186+
187+
etag = info['ETag']
188+
etag = '"' + etag + '"'
189+
yield name, etag
190+
191+
def _put(self, href, item, etag):
192+
headers = {'Content-Type': self.item_mimetype + '; charset=UTF-8'}
193+
if etag is None:
194+
headers['If-None-Match'] = '*'
195+
else:
196+
headers['If-Match'] = etag
197+
198+
response = self.session.request(
199+
'PUT',
200+
href,
201+
data=item.raw.encode('utf-8'),
202+
headers=headers
203+
)
204+
if not response.url.endswith('/' + href):
205+
raise exceptions.InvalidResponse('spec doesn\'t allow redirects')
206+
return href, response.headers['etag']
207+
208+
def update(self, href, item, etag):
209+
assert etag
210+
href, etag = self._put(href, item, etag)
211+
return etag
212+
213+
def upload(self, item):
214+
href = utils.generate_href(item.ident)
215+
href = utils.compat.urlquote(href, '@') + self.fileext
216+
return self._put(href, item, None)
217+
218+
def delete(self, href, etag):
219+
headers = {'If-Match': etag}
220+
self.session.request('DELETE', href, headers=headers)
221+
222+
def get(self, href):
223+
response = self.session.request('GET', href)
224+
return Item(response.text), response.headers['etag']
225+
226+
def get_meta(self, key):
227+
try:
228+
return self.session.request('GET', key).text or None
229+
except exceptions.NotFoundError:
230+
pass
231+
232+
def set_meta(self, key, value):
233+
self.session.request(
234+
'PUT',
235+
key,
236+
data=value.encode('utf-8'),
237+
headers={'Content-Type': 'text/plain'}
238+
)
239+
240+
241+
class RemoteStorageContacts(RemoteStorage):
242+
__doc__ = '''
243+
remoteStorage contacts. Uses the `vdir_contacts` scope.
244+
''' + RemoteStorage.__doc__
245+
246+
storage_name = 'remotestorage_contacts'
247+
fileext = '.vcf'
248+
item_mimetype = 'text/vcard'
249+
scope = 'vdir_contacts'
250+
251+
def __init__(self, **kwargs):
252+
if kwargs.get('collection'):
253+
raise ValueError(
254+
'No collections allowed for contacts, '
255+
'there is only one addressbook. '
256+
'Use the vcard groups construct to categorize your contacts '
257+
'into groups.'
258+
)
259+
260+
super(RemoteStorageContacts, self).__init__(**kwargs)
261+
262+
263+
class RemoteStorageCalendars(RemoteStorage):
264+
__doc__ = '''
265+
remoteStorage calendars. Uses the `vdir_calendars` scope.
266+
''' + RemoteStorage.__doc__
267+
268+
storage_name = 'remotestorage_calendars'
269+
fileext = '.ics'
270+
item_mimetype = 'text/icalendar'
271+
scope = 'vdir_calendars'
272+
273+
def __init__(self, **kwargs):
274+
if not kwargs.get('collection'):
275+
raise ValueError('The collections parameter is required.')
276+
277+
super(RemoteStorageCalendars, self).__init__(**kwargs)

0 commit comments

Comments
 (0)