|
| 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