Skip to content

Commit 248d94a

Browse files
committed
remoteStorage implementation
1 parent 50c370a commit 248d94a

File tree

5 files changed

+283
-4
lines changed

5 files changed

+283
-4
lines changed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,7 @@ install-docs:
5252
pip install sphinx sphinx_rtd_theme
5353

5454
docs:
55-
cd docs
56-
make html
55+
cd ./docs && make html
5756

5857
linkcheck:
5958
sphinx-build -W -b linkcheck ./docs/ ./docs/_build/linkcheck/
@@ -65,3 +64,4 @@ release:
6564
python setup.py sdist bdist_wheel upload
6665

6766
.DEFAULT_GOAL := install
67+
.PHONY: docs

docs/conf.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ def format_signature(self):
9999
def get_doc(self, encoding=None, ignore=1):
100100
from vdirsyncer.cli.utils import format_storage_config
101101
rv = autodoc.ClassDocumenter.get_doc(self, encoding, ignore)
102-
config = [u' ' + x for x in format_storage_config(self.object)]
103-
rv[0] = rv[0][:1] + [u'::', u''] + config + [u''] + rv[0][1:]
102+
if rv:
103+
config = [u' ' + x for x in format_storage_config(self.object)]
104+
rv[0] = rv[0][:1] + [u'::', u''] + config + [u''] + rv[0][1:]
104105
return rv
105106

106107

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

0 commit comments

Comments
 (0)