Skip to content

Commit

Permalink
Merge af036bc into f0c1898
Browse files Browse the repository at this point in the history
  • Loading branch information
untitaker committed Sep 18, 2015
2 parents f0c1898 + af036bc commit be8175b
Show file tree
Hide file tree
Showing 5 changed files with 269 additions and 4 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@ install-docs:
pip install sphinx sphinx_rtd_theme

docs:
cd docs
make html
cd ./docs && make html

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

.DEFAULT_GOAL := install
.PHONY: docs
5 changes: 3 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@ def format_signature(self):
def get_doc(self, encoding=None, ignore=1):
from vdirsyncer.cli.utils import format_storage_config
rv = autodoc.ClassDocumenter.get_doc(self, encoding, ignore)
config = [u' ' + x for x in format_storage_config(self.object)]
rv[0] = rv[0][:1] + [u'::', u''] + config + [u''] + rv[0][1:]
if rv:
config = [u' ' + x for x in format_storage_config(self.object)]
rv[0] = rv[0][:1] + [u'::', u''] + config + [u''] + rv[0][1:]
return rv


Expand Down
14 changes: 14 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,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
~~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 4 additions & 0 deletions vdirsyncer/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def __init__(self):
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):
Expand Down
246 changes: 246 additions & 0 deletions vdirsyncer/storage/remotestorage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@

import click

from oauthlib.oauth2 import MobileApplicationClient

from requests_oauthlib import OAuth2Session

from .base import Item, Storage
from .http import HTTP_STORAGE_PARAMETERS, USERAGENT, prepare_client_cert, \
prepare_verify
from .. import exceptions, log, utils

CLIENT_ID = USERAGENT
DRAFT_VERSION = '05'

logger = log.get(__name__)

urljoin = utils.compat.urlparse.urljoin


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):
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'
if collection:
scope = urljoin(_ensure_slash(scope),
_ensure_slash(collection))

self._session = OAuth2Session(
CLIENT_ID, client=MobileApplicationClient(CLIENT_ID),
scope=self.scope,
redirect_uri=('data:text/html,<script>'
'document.write(location.hash);</script>'),
token={'access_token': access_token},
)
self._discover_endpoints(scope)

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))
fragment = click.prompt('What is on the webpage?')
self._session.token_from_fragment('https://fuckyou.com/' + fragment)
click.echo('Paste this into your storage configuration:\n'
'access_token = "{}"\n'
'Aborting synchronization.'
.format(self._session.token['access_token']))
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 'draft-dejong-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)
if not self.collection:
raise ValueError('collection must not be null.')

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.encode('utf-8'),
headers={'Content-Type': 'text/plain'}
)


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'


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'

0 comments on commit be8175b

Please sign in to comment.