remoteStorage implementation

This commit is contained in:
Markus Unterwaditzer 2015-08-19 18:56:32 +02:00
parent 3627c6e810
commit 6ceeb99f4a
3 changed files with 293 additions and 0 deletions

View file

@ -120,14 +120,28 @@ These storages generally support reading and changing of their items. Their
default value for ``read_only`` is ``false``, but can be set to ``true`` if
wished.
CalDAV and CardDAV
++++++++++++++++++
.. autostorage:: vdirsyncer.storage.dav.CaldavStorage
.. autostorage:: vdirsyncer.storage.dav.CarddavStorage
remoteStorage
+++++++++++++
.. autostorage:: vdirsyncer.storage.remotestorage.RemoteStorageContacts
.. autostorage:: vdirsyncer.storage.remotestorage.RemoteStorageCalendars
Local
+++++
.. autostorage:: vdirsyncer.storage.filesystem.FilesystemStorage
.. autostorage:: vdirsyncer.storage.singlefile.SingleFileStorage
Read-only storages
~~~~~~~~~~~~~~~~~~

View file

@ -41,6 +41,10 @@ class _StorageIndex(object):
filesystem='vdirsyncer.storage.filesystem.FilesystemStorage',
http='vdirsyncer.storage.http.HttpStorage',
singlefile='vdirsyncer.storage.singlefile.SingleFileStorage',
remotestorage_contacts=(
'vdirsyncer.storage.remotestorage.RemoteStorageContacts'),
remotestorage_calendars=(
'vdirsyncer.storage.remotestorage.RemoteStorageCalendars'),
)
def __getitem__(self, name):

View file

@ -0,0 +1,275 @@
'''
A storage type for accessing contact and calendar data from `remoteStorage
<https://remotestorage.io>`_. It is highly experimental.
A few things are hardcoded for now so the user doesn't have to specify those
things, and plugging in an account "just works".
We also use a custom ``data``-URI for the redirect in OAuth:
- There is no server that could be compromised.
- With a proper URL, ``access_token`` would be stored in the browser history.
For some reason Firefox doesn't do that with ``data``-URIs.
- ``data``-URIs have no clear domain name that could prevent from phishing
attacks. However, I don't see a way to phish without compromising the
vdirsyncer installation, at which point any hope would already be lost.
- On the downside, redirect URIs are monstrous.
'''
import click
from .base import Item, Storage
from .http import HTTP_STORAGE_PARAMETERS, USERAGENT, prepare_client_cert, \
prepare_verify
from .. import exceptions, log, utils
REDIRECT_URI = 'https://vdirsyncer.5apps.com/'
CLIENT_ID = 'https://vdirsyncer.5apps.com'
DRAFT_VERSION = '05'
logger = log.get(__name__)
urljoin = utils.compat.urlparse.urljoin
urlquote = utils.compat.urlquote
def _ensure_slash(dir):
return dir.rstrip('/') + '/'
def _iter_listing(json):
new_listing = '@context' in json # draft-02 and beyond
if new_listing:
json = json['items']
for name, info in utils.compat.iteritems(json):
if not new_listing:
info = {'ETag': info}
yield name, info
class Session(object):
def __init__(self, account, scope, verify=True, verify_fingerprint=None,
auth_cert=None, access_token=None, collection=None):
from oauthlib.oauth2 import MobileApplicationClient
from requests_oauthlib import OAuth2Session
self.user, self.host = account.split('@')
self._settings = {
'cert': prepare_client_cert(auth_cert)
}
self._settings.update(prepare_verify(verify, verify_fingerprint))
self.scope = scope + ':rw'
self._session = OAuth2Session(
CLIENT_ID, client=MobileApplicationClient(CLIENT_ID),
scope=self.scope,
redirect_uri=REDIRECT_URI,
token={'access_token': access_token},
)
subpath = scope
if collection:
subpath = urljoin(_ensure_slash(scope),
_ensure_slash(urlquote(collection)))
self._discover_endpoints(subpath)
if not access_token:
self._get_access_token()
def request(self, method, path, **kwargs):
url = self.endpoints['storage']
if path:
url = urljoin(url, path)
settings = dict(self._settings)
settings.update(kwargs)
return utils.http.request(method, url,
session=self._session, **settings)
def _get_access_token(self):
authorization_url, state = \
self._session.authorization_url(self.endpoints['oauth'])
click.echo('Go to {}'.format(authorization_url))
click.echo('Follow the instructions on the page.')
raise exceptions.UserError('Aborted!')
def _discover_endpoints(self, subpath):
r = utils.http.request(
'GET', 'https://{host}/.well-known/webfinger?resource=acct:{user}'
.format(host=self.host, user=self.user),
**self._settings
)
j = r.json()
for link in j['links']:
if 'remotestorage' in link['rel']:
break
storage = urljoin(_ensure_slash(link['href']),
_ensure_slash(subpath))
props = link['properties']
oauth = props['http://tools.ietf.org/html/rfc6749#section-4.2']
self.endpoints = dict(storage=storage, oauth=oauth)
class RemoteStorage(Storage):
__doc__ = '''
:param account: remoteStorage account, ``"user@example.com"``.
''' + HTTP_STORAGE_PARAMETERS + '''
'''
storage_name = None
item_mimetype = None
fileext = None
def __init__(self, account, verify=True, verify_fingerprint=None,
auth_cert=None, access_token=None, **kwargs):
super(RemoteStorage, self).__init__(**kwargs)
self.session = Session(
account=account,
verify=verify,
verify_fingerprint=verify_fingerprint,
auth_cert=auth_cert,
access_token=access_token,
collection=self.collection,
scope=self.scope)
@classmethod
def discover(cls, **base_args):
if base_args.pop('collection', None) is not None:
raise TypeError('collection argument must not be given.')
session_args, _ = utils.split_dict(base_args, lambda key: key in (
'account', 'verify', 'auth', 'verify_fingerprint', 'auth_cert',
'access_token'
))
session = Session(scope=cls.scope, **session_args)
try:
r = session.request('GET', '')
except exceptions.NotFoundError:
return
for name, info in _iter_listing(r.json()):
if not name.endswith('/'):
continue # not a folder
newargs = dict(base_args)
newargs['collection'] = name.rstrip('/')
yield newargs
@classmethod
def create_collection(cls, collection, **kwargs):
# remoteStorage folders are autocreated.
assert collection
assert '/' not in collection
kwargs['collection'] = collection
return kwargs
def list(self):
try:
r = self.session.request('GET', '')
except exceptions.NotFoundError:
return
for name, info in _iter_listing(r.json()):
if not name.endswith(self.fileext):
continue
etag = info['ETag']
etag = '"' + etag + '"'
yield name, etag
def _put(self, href, item, etag):
headers = {'Content-Type': self.item_mimetype + '; charset=UTF-8'}
if etag is None:
headers['If-None-Match'] = '*'
else:
headers['If-Match'] = etag
response = self.session.request(
'PUT',
href,
data=item.raw.encode('utf-8'),
headers=headers
)
if not response.url.endswith('/' + href):
raise exceptions.InvalidResponse('spec doesn\'t allow redirects')
return href, response.headers['etag']
def update(self, href, item, etag):
assert etag
href, etag = self._put(href, item, etag)
return etag
def upload(self, item):
href = utils.generate_href(item.ident)
href = utils.compat.urlquote(href, '@') + self.fileext
return self._put(href, item, None)
def delete(self, href, etag):
headers = {'If-Match': etag}
self.session.request('DELETE', href, headers=headers)
def get(self, href):
response = self.session.request('GET', href)
return Item(response.text), response.headers['etag']
def get_meta(self, key):
try:
return self.session.request('GET', key).text or None
except exceptions.NotFoundError:
pass
def set_meta(self, key, value):
self.session.request(
'PUT',
key,
data=(value or u'').encode('utf-8'),
headers={'Content-Type': 'text/plain; charset=utf-8'}
)
class RemoteStorageContacts(RemoteStorage):
__doc__ = '''
remoteStorage contacts. Uses the `vdir_contacts` scope.
''' + RemoteStorage.__doc__
storage_name = 'remotestorage_contacts'
fileext = '.vcf'
item_mimetype = 'text/vcard'
scope = 'vdir_contacts'
def __init__(self, **kwargs):
if kwargs.get('collection'):
raise ValueError(
'No collections allowed for contacts, '
'there is only one addressbook. '
'Use the vcard groups construct to categorize your contacts '
'into groups.'
)
super(RemoteStorageContacts, self).__init__(**kwargs)
class RemoteStorageCalendars(RemoteStorage):
__doc__ = '''
remoteStorage calendars. Uses the `vdir_calendars` scope.
''' + RemoteStorage.__doc__
storage_name = 'remotestorage_calendars'
fileext = '.ics'
item_mimetype = 'text/icalendar'
scope = 'vdir_calendars'
def __init__(self, **kwargs):
if not kwargs.get('collection'):
raise ValueError('The collections parameter is required.')
super(RemoteStorageCalendars, self).__init__(**kwargs)