pax_global_header 0000666 0000000 0000000 00000000064 13715155050 0014514 g ustar 00root root 0000000 0000000 52 comment=996a6a9481ce50c97069c734a91e35eabd34fca4
pyetesync-0.12.1/ 0000775 0000000 0000000 00000000000 13715155050 0013620 5 ustar 00root root 0000000 0000000 pyetesync-0.12.1/.flake8 0000664 0000000 0000000 00000000037 13715155050 0014773 0 ustar 00root root 0000000 0000000 [flake8]
max-line-length = 120
pyetesync-0.12.1/.gitignore 0000664 0000000 0000000 00000000111 13715155050 0015601 0 ustar 00root root 0000000 0000000 Session.vim
/.venv
/.coverage
/etesync.db
__pycache__
*.egg-info
.*.swp
pyetesync-0.12.1/.travis.yml 0000664 0000000 0000000 00000000245 13715155050 0015732 0 ustar 00root root 0000000 0000000 dist: xenial
language: python
python:
- "3.5"
- "3.6"
- "3.7"
install:
- pip install -r requirements.in/requirements-dev.txt
script:
- ./run_unit_tests.sh
pyetesync-0.12.1/ChangeLog.md 0000664 0000000 0000000 00000005163 13715155050 0015776 0 ustar 00root root 0000000 0000000 # Changelog
## Version 0.12.1
* Change how we import scrypt from hashlib to be more pythonic
## Version 0.12.0
* Update cryptography dep
## Version 0.11.1
* Use the hashlib scrypt implementation if available, otherwise fallback to scrypt or pyscrypt
## Version 0.11.0
* Make sync faster by only fetching entries when journals have changed.
* Change the scrypt dep in setup.py to scrypt (from pyscrypt, which is still supported as a fallback).
* Set the user agent when making requests.
## Version 0.10.0
* Change database model to WAL which should improve concurrency
## Version 0.9.3
* Provide more explicit copyright and licensing information.
## Version 0.9.2
* Remove dev dep (coverage) from setup.py
## Version 0.9.1
* Gracefully handle entries without a UID.
## Version 0.9.0
* Fix reinit of the EteSync object (fixes tests)
* Bump minor version as should have been done in the previous release.
## Version 0.8.4
* Allow users of this library to add tables to the cache database.
* Make the sqlite database more strict (enforce foreign key relations).
## Version 0.8.3
* Fix typo with pytz dependency
## Version 0.8.2
* Add missing pytz dependency
* Upgrade urllib3 and requests
## Version 0.8.1
* Fix pushing entries to shared journals.
## Version 0.8.0
* Add support for read only journals
* Fix issue with having the same journals in the db for different users.
* Journal list fetching: fix stale cache issue.
## Version 0.7.0
* Fix user info to use the correct asymmetric key format
* Sync user info on every protocol sync - needed for encryption password changes.
## Version 0.6.3
* Sync: automatically create user info if doesn't exist
## Version 0.6.2
* Add shell scripts for executing tests
## Version 0.6.1
* Fix journal integrity issue when syncing more than one collection item.
## Version 0.6.0
* Add tasks support
## Version 0.5.6
* Fix broken calling to scrypt
* Fix sync (was broken in some cases) and tests
## Version 0.5.5
* Automatically detect if scrypt is available. If so use it, otherwise revert to pyscript. Setup.py dep remains on pyscypt.
* This is to help distros that don't package pyscrypt
* Update peewee to support version 3 and up
## Version 0.5.4
* Fix peewee dep to be < 3.0.0
## Version 0.5.3
* Change back to pyscript, because scrypt has proven very problematic
* Update all the deps
## Version 0.5.2
* Don't install tests as a package
## Version 0.5.1
* Change from pyscrypt to scrypt (much faster and widely adopted)
## Version 0.5.0
* Add functions to check if the journal list or journals are dirty.
* Add a nicer way to access the journal's info.
* Fix an issue with caching user info
* Improve tests
pyetesync-0.12.1/LICENSE 0000664 0000000 0000000 00000016743 13715155050 0014640 0 ustar 00root root 0000000 0000000 GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
pyetesync-0.12.1/MANIFEST.in 0000664 0000000 0000000 00000000042 13715155050 0015352 0 ustar 00root root 0000000 0000000 include LICENSE
include README.md
pyetesync-0.12.1/README.md 0000664 0000000 0000000 00000005775 13715155050 0015115 0 ustar 00root root 0000000 0000000
EteSync - Secure Data Sync
This is a python client library for [EteSync](https://www.etesync.com)

[](https://pypi.python.org/pypi/etesync/)
[](https://travis-ci.com/etesync/pyetesync)
[](https://webchat.freenode.net/?channels=#etesync)
This module provides a python API to interact with an EteSync server.
It currently implements AddressBook and Calendar access, and supports two-way
sync (both push and pull) to the server.
It doesn't currently implement pushing raw journal entries which are needed for
people implementing new EteSync journal types which will be implemented soon.
To install, please run:
```
pip install etesync
```
The module works and the API is tested (see [tests/](tests/)), however there still
may be some oddities, so please report if you encounter any.
There is one Authenticator endpoint, and one endpoint for the rest of the API
interactions.
The way it works is that you run "sync", which syncs local cache with server.
Afterwards you can either access the journal directly, or if you prefer,
you can access a collection, for example a Calendar, and interact with the
entries themselves, which are already in sync with the journal.
Check out [example.py](example.py) for a basic usage example, or the tests
for a more complete example.
While this is stable enough for usage, it still may be subject to change, so
please watch out for the changelog when updating version.
Docs are currently missing but are planned.
## Running the example script
You may also need to make sure you have the OpenSSL development package
installed (e.g. `openssl-dev`).
Check out this repository:
```
git clone git@github.com:etesync/pyetesync.git
cd pyetesync
```
Setup the environment:
```
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.in/requirements-dev.txt
```
Run `example.py` to export your data:
```
python3 example.py https://api.etesync.com
```
You may need to surround your passwords in quotes and you may need to escape special characters with a `\`.
Please note, that depending on your setup, passing your passwords as command line parameters may not be completely secure,
so it would be better if you manually edit the file.
And all of your data will be copied to a local database located at `~/.etesync/data.db`.
## Running the tests
Some to the tests are unit tests, but some are integration tests who need an actual EteSync service with a few user names set up in order for them to work.
You'd need to run your local server: https://github.com/etesync/server-skeleton/
And then add two users:
- test@localhost
- test2@localhost
Password for both: SomePassword
That's it.
pyetesync-0.12.1/etesync/ 0000775 0000000 0000000 00000000000 13715155050 0015272 5 ustar 00root root 0000000 0000000 pyetesync-0.12.1/etesync/__init__.py 0000664 0000000 0000000 00000001166 13715155050 0017407 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
from .api import *
pyetesync-0.12.1/etesync/_version.py 0000664 0000000 0000000 00000001172 13715155050 0017471 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
__version__ = "0.12.1"
pyetesync-0.12.1/etesync/api.py 0000664 0000000 0000000 00000044135 13715155050 0016424 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import vobject
import json
import os
import peewee
from .crypto import CryptoManager, AsymmetricCryptoManager, AsymmetricKeyPair, derive_key, CURRENT_VERSION
from .service import JournalManager, EntryManager, SyncEntry
from . import cache, pim, service, db, exceptions
API_URL = 'https://api.etesync.com/'
# Expose the authenticator
Authenticator = service.Authenticator
class EteSync:
def __init__(self, email, auth_token, remote=API_URL, cipher_key=None, db_path=None):
self.email = email
self.auth_token = auth_token
self.remote = remote
self.cipher_key = cipher_key
self._init_db(db_path)
def reinit(self):
self._set_db(self._database)
def _set_db(self, database):
self._database = database
db.database_proxy.initialize(database)
self._init_db_tables(database)
self.user, created = cache.User.get_or_create(username=self.email)
def _init_db(self, db_path):
from playhouse.sqlite_ext import SqliteExtDatabase
if db_path is None:
db_path = os.path.join(os.path.expanduser('~'), '.etesync', 'data.db')
directory = os.path.dirname(db_path)
if directory != '' and not os.path.exists(directory):
os.makedirs(directory)
database = SqliteExtDatabase(db_path, pragmas={
'journal_mode': 'wal',
'foreign_keys': 1,
})
database.connect()
self._set_db(database)
def _init_db_tables(self, database, additional_tables=None):
CURRENT_DB_VERSION = 2
new_db = not database.table_exists('journalentity')
database.create_tables([cache.Config, pim.Content, cache.User, cache.JournalEntity,
cache.EntryEntity, cache.UserInfo], safe=True)
if additional_tables:
database.create_tables(additional_tables, safe=True)
default_db_version = CURRENT_DB_VERSION if new_db else 0
config, created = cache.Config.get_or_create(defaults={'db_version': default_db_version})
if config.db_version < 1:
from playhouse.migrate import SqliteMigrator, migrate
# Essentially version 0 so do first migration.
migrator = SqliteMigrator(database)
try:
migrate(
migrator.add_column('journalentity', 'read_only', cache.JournalEntity.read_only),
)
except peewee.OperationalError:
# A hack because we don't have a db config yet.
pass
config.db_version = 1
config.save()
if config.db_version < 2:
from playhouse.migrate import SqliteMigrator, migrate
migrator = SqliteMigrator(database)
migrate(
migrator.add_column('journalentity', 'remote_last_uid', cache.JournalEntity.remote_last_uid),
)
config.db_version = 2
config.save()
def get_or_create_user_info(self, force_fetch=False):
user_info = None
try:
user_info = cache.UserInfo.get(user=self.user)
except cache.UserInfo.DoesNotExist:
pass
if user_info is None or force_fetch:
info_manager = service.UserInfoManager(self.remote, self.auth_token)
remote_info = None
try:
remote_info = info_manager.get(self.user.username, self.cipher_key)
except exceptions.HttpNotFound:
pass
if remote_info:
remote_info.verify()
else:
key_pair = AsymmetricCryptoManager.generate_key_pair()
crypto_manager = CryptoManager(CURRENT_VERSION, self.cipher_key, b'userInfo')
remote_info = service.RawUserInfo(crypto_manager, self.user.username, key_pair.public_key)
remote_info.update(key_pair.private_key)
remote_info.verify()
info_manager.add(remote_info)
new_user_info = user_info is None
user_info = cache.UserInfo(user=self.user, pubkey=remote_info.pubkey, content=remote_info.getContent())
user_info.save(force_insert=new_user_info)
return user_info
def sync(self):
self.get_or_create_user_info(force_fetch=True)
self.sync_journal_list()
for journal in self.list():
self.sync_journal(journal.uid)
def sync_journal_list(self):
self.push_journal_list()
manager = JournalManager(self.remote, self.auth_token)
existing = {}
for journal in self.list():
existing[journal.uid] = journal._cache_obj
for entry in manager.list(self.cipher_key):
entry.crypto_manager = self._get_journal_cryptomanager(entry)
entry.verify()
if entry.uid in existing:
journal = existing[entry.uid]
del existing[journal.uid]
else:
journal = cache.JournalEntity(local_user=self.user, uid=entry.uid, owner=entry.owner)
journal.version = entry.version
journal.encrypted_key = entry.encrypted_key
journal.read_only = entry.read_only
journal.remote_last_uid = entry.remote_last_uid
journal.content = entry.getContent()
journal.save()
# Delete remaining
for journal in existing.values():
journal.deleted = True
journal.save()
def _journal_list_dirty_get(self):
return self.user.journals.where(cache.JournalEntity.dirty | cache.JournalEntity.new)
def journal_list_is_dirty(self):
changed = list(self._journal_list_dirty_get())
return len(changed) > 0
def push_journal_list(self):
manager = JournalManager(self.remote, self.auth_token)
changed = self._journal_list_dirty_get()
for journal in changed:
crypto_manager = self._get_journal_cryptomanager(journal)
raw_journal = service.RawJournal(crypto_manager, uid=journal.uid)
raw_journal.update(journal.content)
if journal.deleted:
manager.delete(raw_journal)
elif journal.new:
manager.add(raw_journal)
journal.new = False
else:
manager.update(raw_journal)
journal.dirty = False
journal.save()
def sync_journal(self, uid):
# FIXME: At the moment if there's a conflict remote would win.
self.pull_journal(uid)
self.push_journal(uid)
def _get_journal_cryptomanager(self, journal):
if journal.encrypted_key is not None:
# If journal is pubkey encrypted, fetch encryption key
user_info = self.get_or_create_user_info()
key_pair = AsymmetricKeyPair(user_info.content, user_info.pubkey)
return CryptoManager.create_from_asymmetric_encryted_key(
journal.version, key_pair, journal.encrypted_key)
else:
cipher_key = self.cipher_key
return CryptoManager(journal.version, cipher_key, journal.uid.encode())
def _get_last_entry(self, journal):
try:
return journal.entries.order_by(cache.EntryEntity.id.desc()).get()
except cache.EntryEntity.DoesNotExist:
return None
def pull_journal(self, uid):
journal_uid = uid
manager = EntryManager(self.remote, self.auth_token, journal_uid)
journal = cache.JournalEntity.get(local_user=self.user, uid=journal_uid)
crypto_manager = self._get_journal_cryptomanager(journal)
collection = Journal._from_cache(journal).collection
prev = self._get_last_entry(journal)
last_uid = None if prev is None else prev.uid
if (last_uid is not None) and (last_uid == journal.remote_last_uid):
return
for entry in manager.list(crypto_manager, last_uid):
entry.verify(prev)
content = entry.getContent()
syncEntry = SyncEntry.from_json(content.decode())
collection.apply_sync_entry(syncEntry)
cache.EntryEntity.create(uid=entry.uid, content=content, journal=journal)
prev = entry
def _journal_dirty_get(self, journal):
return journal.content_set.where(pim.Content.new | pim.Content.dirty | pim.Content.deleted)
def journal_is_dirty(self, uid):
journal = cache.JournalEntity.get(local_user=self.user, uid=uid)
changed = list(self._journal_dirty_get(journal))
return len(changed) > 0
def push_journal(self, uid):
# FIXME: Implement pushing in chunks
journal_uid = uid
manager = EntryManager(self.remote, self.auth_token, journal_uid)
journal = cache.JournalEntity.get(local_user=self.user, uid=journal_uid)
crypto_manager = self._get_journal_cryptomanager(journal)
changed_set = self._journal_dirty_get(journal)
changed = list(changed_set)
if len(changed) == 0:
return
prev = self._get_last_entry(journal)
last_uid = None if prev is None else prev.uid
entries = []
for pim_entry in changed:
if pim_entry.deleted:
action = 'DELETE'
elif pim_entry.new:
action = 'ADD'
else:
action = 'CHANGE'
sync_entry = SyncEntry(action, pim_entry.content)
raw_entry = service.RawEntry(crypto_manager)
raw_entry.update(sync_entry.to_json().encode(), prev)
entries.append(raw_entry)
prev = raw_entry
manager.add(entries, last_uid)
# Add entries to cache
for entry in entries:
cache.EntryEntity.create(uid=entry.uid, content=entry.getContent(), journal=journal)
# Clear dirty flags and delete deleted content
pim.Content.delete().where((pim.Content.journal == journal) & pim.Content.deleted).execute()
pim.Content.update(new=False, dirty=False).where(
(pim.Content.journal == journal) & (pim.Content.new | pim.Content.dirty)
).execute()
def derive_key(self, password):
self.cipher_key = derive_key(password, self.email)
return self.cipher_key
# CRUD operations
def list(self):
for cache_obj in self.user.journals.where(~cache.JournalEntity.deleted):
yield Journal._from_cache(cache_obj)
def get(self, uid):
try:
return Journal._from_cache(self.user.journals.where(
(cache.JournalEntity.uid == uid) & ~cache.JournalEntity.deleted).get())
except cache.JournalEntity.DoesNotExist as e:
raise exceptions.DoesNotExist(e)
class ApiObjectBase:
def __init__(self):
self._cache_obj = None
def __repr__(self):
return '<{} {}>'.format(self.__class__.__name__, self.uid)
@classmethod
def _from_cache(cls, cache_obj):
ret = cls()
ret._cache_obj = cache_obj
return ret
@classmethod
def create(cls, journal, uid, content):
cache_obj = cls._CACHE_OBJ_CLASS()
cache_obj.journal = journal._cache_obj
cache_obj.uid = uid
cache_obj.content = content
cache_obj.new = True
return cls._from_cache(cache_obj)
@property
def uid(self):
if self._cache_obj.uid is None:
return None
return str(self._cache_obj.uid)
@uid.setter
def uid(self, uid):
self._cache_obj.uid = uid
@property
def content(self):
return self._cache_obj.content
@content.setter
def content(self, content):
self._cache_obj.content = content
def save(self):
try:
self._cache_obj.save()
except peewee.IntegrityError as e:
if 'UNIQUE' in str(e):
raise exceptions.AlreadyExists(e)
else:
raise exceptions.DoesNotExist(e)
class Entry(ApiObjectBase):
_CACHE_OBJ_CLASS = cache.EntryEntity
class PimObject(ApiObjectBase):
_CACHE_OBJ_CLASS = pim.Content
@classmethod
def create(cls, collection, content):
if collection.get_content_class() != cls:
raise exceptions.TypeMismatch('Collection "{}" does not allow "{}" children.'.format(
collection.__class__.__name__, cls.__name__))
ret = super().create(collection.journal, None, None)
ret.content = content
if ret.uid is None:
return None
return ret
@property
def content(self):
return self._cache_obj.content
@content.setter
def content(self, content):
self._cache_obj.content = content
self.uid = self.__class__.get_uid(content)
def delete(self):
self._cache_obj.deleted = True
self._cache_obj.save()
def save(self):
self._cache_obj.dirty = True
super().save()
class Event(PimObject):
@classmethod
def get_uid(cls, content):
vobj = vobject.readOne(content)
try:
return vobj.vevent.uid.value
except AttributeError:
return None
class Contact(PimObject):
@classmethod
def get_uid(cls, content):
vobj = vobject.readOne(content)
try:
return vobj.uid.value
except AttributeError:
return None
class Task(PimObject):
@classmethod
def get_uid(cls, content):
vobj = vobject.readOne(content)
try:
return vobj.vtodo.uid.value
except AttributeError:
return None
class BaseCollection:
def __init__(self, journal):
self._journal = journal
self._cache_obj = journal._cache_obj
if self._journal.info is None:
self.update_info(None)
@property
def display_name(self):
return self._journal.info.get('displayName')
@property
def description(self):
return self._journal.info.get('description')
@property
def journal(self):
return self._journal
def update_info(self, update_info):
if update_info is None:
self._journal.update_info(self._get_default_info())
else:
self._journal.update_info(update_info)
def _get_default_info(self):
return {'type': self.__class__.TYPE, 'readOnly': False, 'selected': True}
def apply_sync_entry(self, sync_entry):
journal = self._cache_obj
uid = self.get_content_class().get_uid(sync_entry.content)
if uid is None:
print("WARNING: uid not found for entry, skipping. Content:")
print(sync_entry.content)
return
try:
content = pim.Content.get(uid=uid, journal=journal)
except pim.Content.DoesNotExist:
content = None
if sync_entry.action == 'DELETE':
if content is not None:
content.delete_instance()
else:
print("WARNING: Failed to delete " + uid)
return
content = pim.Content(journal=journal, uid=uid) if content is None else content
content.content = sync_entry.content
content.save()
# CRUD
def list(self):
for content in self._cache_obj.content_set.where(~pim.Content.deleted):
yield self.get_content_class()._from_cache(content)
def get(self, uid):
try:
return self.get_content_class()._from_cache(self._cache_obj.content_set.where(pim.Content.uid == uid).get())
except pim.Content.DoesNotExist as e:
raise exceptions.DoesNotExist(e)
@classmethod
def create(cls, etesync, uid, content):
cache_obj = cache.JournalEntity(new=True)
cache_obj.local_user = etesync.user
cache_obj.uid = uid
cache_obj.version = CURRENT_VERSION
ret = cls(Journal._from_cache(cache_obj))
ret.update_info(content)
return ret
def delete(self):
self._cache_obj.deleted = True
self._cache_obj.dirty = True
self._cache_obj.save()
def save(self):
self._cache_obj.dirty = True
try:
self._cache_obj.save()
except peewee.IntegrityError as e:
raise exceptions.AlreadyExists(e)
class Calendar(BaseCollection):
TYPE = 'CALENDAR'
def get_content_class(self):
return Event
class TaskList(BaseCollection):
TYPE = 'TASKS'
def get_content_class(self):
return Task
class AddressBook(BaseCollection):
TYPE = 'ADDRESS_BOOK'
def get_content_class(self):
return Contact
class Journal(ApiObjectBase):
@property
def version(self):
return self._cache_obj.version
@property
def read_only(self):
return self._cache_obj.read_only
@property
def collection(self):
journal_info = self.info
if journal_info.get('type') == AddressBook.TYPE:
return AddressBook(self)
elif journal_info.get('type') == Calendar.TYPE:
return Calendar(self)
elif journal_info.get('type') == TaskList.TYPE:
return TaskList(self)
@property
def info(self):
if self._cache_obj.content is not None:
return json.loads(self._cache_obj.content.decode())
def update_info(self, update_info):
if update_info is None:
raise RuntimeError("update_info can't be None.")
else:
journal_info = self.info
if journal_info is None:
journal_info = {}
journal_info.update(update_info)
self._cache_obj.content = json.dumps(journal_info, ensure_ascii=False).encode()
# CRUD
def list(self):
for entry in self._cache_obj.entries:
yield Entry._from_cache(entry)
pyetesync-0.12.1/etesync/cache.py 0000664 0000000 0000000 00000003702 13715155050 0016711 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import peewee as pw
from . import db
class Config(db.BaseModel):
db_version = pw.IntegerField()
class User(db.BaseModel):
username = pw.CharField(unique=True, null=False)
class JournalEntity(db.BaseModel):
local_user = pw.ForeignKeyField(User, backref='journals')
version = pw.IntegerField()
uid = pw.CharField(null=False, index=True)
owner = pw.CharField(null=True)
encrypted_key = pw.BlobField(null=True)
content = pw.BlobField()
new = pw.BooleanField(null=False, default=False)
dirty = pw.BooleanField(null=False, default=False)
deleted = pw.BooleanField(null=False, default=False)
read_only = pw.BooleanField(null=False, default=False)
remote_last_uid = pw.CharField(null=True, default=None)
class Meta:
indexes = (
(('local_user', 'uid'), True),
)
class EntryEntity(db.BaseModel):
journal = pw.ForeignKeyField(JournalEntity, backref='entries')
uid = pw.CharField(null=False, index=True)
content = pw.BlobField()
new = pw.BooleanField(null=False, default=False)
class Meta:
indexes = (
(('journal', 'uid'), True),
)
order_by = ('id', )
class UserInfo(db.BaseModel):
user = pw.ForeignKeyField(User, primary_key=True, backref='user_info')
pubkey = pw.BlobField(null=False)
content = pw.BlobField(null=False)
pyetesync-0.12.1/etesync/crypto.py 0000664 0000000 0000000 00000013246 13715155050 0017172 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization, hashes, padding
from cryptography.hazmat.primitives.asymmetric import rsa, padding as asym_padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import os
import hashlib
import hmac
try:
from hashlib import scrypt
def derive_key(user_password, salt):
return scrypt(password=user_password.encode(),
salt=salt.encode(),
n=16384,
r=8,
p=1,
dklen=190)
except ImportError:
try:
import scrypt
def derive_key(user_password, salt):
return scrypt.hash(password=user_password.encode(),
salt=salt.encode(),
N=16384,
r=8,
p=1,
buflen=190)
except ImportError:
import pyscrypt
def derive_key(user_password, salt):
return pyscrypt.hash(password=user_password.encode(),
salt=salt.encode(),
N=16384,
r=8,
p=1,
dkLen=190)
from . import exceptions
CURRENT_VERSION = 2
HMAC_SIZE = int(256 / 8) # 256bits in bytes
AES_BLOCK_SIZE = int(128 / 8) # 128bits in bytes
def hmac256(key, data):
return hmac.new(key, data, digestmod=hashlib.sha256).digest()
class AsymmetricKeyPair:
def __init__(self, private_key, public_key):
self.private_key = private_key
self.public_key = public_key
class AsymmetricCryptoManager:
def __init__(self, key_pair):
self.key_pair = key_pair
self._padding = asym_padding.OAEP(
mgf=asym_padding.MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(),
label=None
)
@classmethod
def generate_key_pair(cls):
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=3072,
backend=default_backend()
)
return AsymmetricKeyPair(
private_key.private_bytes(encryption_algorithm=serialization.NoEncryption(),
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8),
private_key.public_key().public_bytes(encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo))
def decrypt(self, ctext):
private_key = serialization.load_der_private_key(
self.key_pair.private_key, password=None, backend=default_backend())
return private_key.decrypt(
ctext,
self._padding
)
def encrypt(self, public_key, clear_text):
public_key = serialization.load_der_public_key(
self.key_pair.public_key, backend=default_backend())
return public_key.encrypt(
clear_text,
self._padding
)
class CryptoManager:
def __init__(self, version, key, salt):
if version > CURRENT_VERSION:
raise exceptions.VersionTooNew("Found version is too new. Found: {} Current: {}".format(
version, CURRENT_VERSION))
elif version == 1:
pass
else:
key = hmac256(salt, key)
self.version = version
self._set_derived_key(key)
@classmethod
def create_from_asymmetric_encryted_key(cls, version, key_pair, encrypted_key):
asymmetric_crypto_manager = AsymmetricCryptoManager(key_pair)
derived_key = asymmetric_crypto_manager.decrypt(encrypted_key)
ret = CryptoManager(version, b'', b'')
ret._set_derived_key(derived_key)
return ret
def _set_derived_key(self, key):
self.cipher_key = hmac256(b'aes', key)
self.hmacKey = hmac256(b'hmac', key)
def decrypt(self, ctext):
iv = ctext[:AES_BLOCK_SIZE]
ctext = ctext[AES_BLOCK_SIZE:]
cipher = Cipher(algorithms.AES(self.cipher_key), modes.CBC(iv), backend=default_backend())
unpadder = padding.PKCS7(AES_BLOCK_SIZE * 8).unpadder()
decryptor = cipher.decryptor()
data = decryptor.update(ctext) + decryptor.finalize()
return unpadder.update(data) + unpadder.finalize()
def encrypt(self, clear_text):
iv = os.urandom(AES_BLOCK_SIZE)
cipher = Cipher(algorithms.AES(self.cipher_key), modes.CBC(iv), backend=default_backend())
padder = padding.PKCS7(AES_BLOCK_SIZE * 8).padder()
encryptor = cipher.encryptor()
padded_data = padder.update(clear_text) + padder.finalize()
return iv + encryptor.update(padded_data) + encryptor.finalize()
def hmac(self, data):
if self.version == 1:
return hmac256(self.hmacKey, data)
else:
return hmac256(self.hmacKey, data + bytes([self.version]))
pyetesync-0.12.1/etesync/db.py 0000664 0000000 0000000 00000001343 13715155050 0016232 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import peewee as pw
database_proxy = pw.Proxy()
class BaseModel(pw.Model):
class Meta:
database = database_proxy
pyetesync-0.12.1/etesync/exceptions.py 0000664 0000000 0000000 00000002271 13715155050 0020027 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
class HttpException(Exception):
pass
class UnauthorizedException(HttpException):
pass
class UserInactiveException(HttpException):
pass
class ServiceUnavailableException(HttpException):
pass
class HttpNotFound(HttpException):
pass
class VersionTooNew(Exception):
pass
class SecurityException(Exception):
pass
class IntegrityException(SecurityException):
pass
class StorageException(Exception):
pass
class DoesNotExist(StorageException):
pass
class AlreadyExists(StorageException):
pass
class TypeMismatch(StorageException):
pass
pyetesync-0.12.1/etesync/pim.py 0000664 0000000 0000000 00000002101 13715155050 0016423 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import peewee as pw
from .cache import JournalEntity
from . import db
class Content(db.BaseModel):
journal = pw.ForeignKeyField(JournalEntity)
uid = pw.CharField(null=False, index=True)
content = pw.TextField()
new = pw.BooleanField(null=False, default=False)
dirty = pw.BooleanField(null=False, default=False)
deleted = pw.BooleanField(null=False, default=False)
class Meta:
indexes = (
(('journal', 'uid'), True),
)
pyetesync-0.12.1/etesync/service.py 0000664 0000000 0000000 00000026273 13715155050 0017316 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import requests
import json
import base64
import binascii
from http import HTTPStatus
from furl import furl
from .crypto import CryptoManager, HMAC_SIZE
from . import exceptions
from ._version import __version__
API_PATH = ('api', 'v1')
USER_AGENT = 'pyetesync/' + __version__
def _status_success(status_code):
return status_code // 100 == 2
class Authenticator:
def __init__(self, remote):
self.remote = furl(remote)
self.remote.path.segments.extend(('api-token-auth', ''))
self.remote.path.normalize()
def get_auth_token(self, username, password):
headers = {'User-Agent': USER_AGENT}
response = requests.post(self.remote.url, data={'username': username, 'password': password}, headers=headers)
if response.status_code == HTTPStatus.BAD_REQUEST:
raise exceptions.UnauthorizedException("Username or password incorrect.")
elif not _status_success(response.status_code):
raise exceptions.HttpException(response.status_code)
data = response.json()
return data['token']
class RawBase:
def __init__(self, crypto_manager, content=None, uid=None):
self.crypto_manager = crypto_manager
self.uid = uid
self.content = content
@property
def version(self):
return self.crypto_manager.version
def getContent(self):
return self.crypto_manager.decrypt(self.content)
def setContent(self, content):
self.content = self.crypto_manager.encrypt(content)
def to_simple(self):
content = base64.b64encode(self.content)
return {'uid': self.uid, 'content': content.decode()}
def _verify_hmac(self, hmac1, hmac2):
if hmac1 != hmac2:
raise exceptions.IntegrityException("HMAC mismatch: {} != {}".format(
binascii.hexlify(hmac1).decode(), binascii.hexlify(hmac2).decode()))
class RawJournal(RawBase):
def __init__(self, crypto_manager, content=None, uid=None, owner=None, encrypted_key=None, read_only=False, remote_last_uid=None):
super().__init__(crypto_manager, content, uid)
if content is not None:
self.hmac = content[:HMAC_SIZE]
self.content = content[HMAC_SIZE:]
self.owner = owner
self.encrypted_key = encrypted_key
self.read_only = read_only
self.remote_last_uid = remote_last_uid
def calc_hmac(self):
return self.crypto_manager.hmac(self.uid.encode() + self.content)
def verify(self):
self._verify_hmac(self.hmac, self.calc_hmac())
def to_simple(self):
content = base64.b64encode(self.hmac + self.content)
return {'uid': self.uid, 'content': content.decode(), 'version': self.version}
def update(self, content):
self.setContent(content)
self.hmac = self.calc_hmac()
class RawEntry(RawBase):
def calc_hmac(self, prev):
prevUid = b''
if prev is not None:
prevUid = prev.uid.encode()
return self.crypto_manager.hmac(prevUid + self.content)
def verify(self, prev):
self._verify_hmac(binascii.unhexlify(self.uid), self.calc_hmac(prev))
def update(self, content, prev):
self.setContent(content)
self.uid = binascii.hexlify(self.calc_hmac(prev)).decode()
class RawUserInfo(RawBase):
def __init__(self, crypto_manager, owner=None, pubkey=None, content=None):
super().__init__(crypto_manager, content, None)
self.owner = owner
self.pubkey = pubkey
if content is not None:
self.hmac = content[:HMAC_SIZE]
self.content = content[HMAC_SIZE:]
def calc_hmac(self):
return self.crypto_manager.hmac(self.content + self.pubkey)
def verify(self):
self._verify_hmac(self.hmac, self.calc_hmac())
def to_simple(self):
content = base64.b64encode(self.hmac + self.content)
pubkey = base64.b64encode(self.pubkey)
return {'owner': self.owner, 'pubkey': pubkey.decode(), 'content': content.decode(), 'version': self.version}
def update(self, content):
self.setContent(content)
self.hmac = self.calc_hmac()
class BaseManager:
def __init__(self, auth_token):
headers = {
'User-Agent': USER_AGENT,
'Authorization': 'Token ' + auth_token,
}
self.requests = requests.Session()
self.requests.headers.update(headers)
def detail_url(self, uid):
remote = self.remote.copy()
remote.path.segments.extend((uid, ''))
remote.path.normalize()
return remote
def _validate_response(self, response):
if response.status_code == HTTPStatus.SERVICE_UNAVAILABLE:
raise exceptions.ServiceUnavailableException("Service unavailable")
elif response.status_code == HTTPStatus.UNAUTHORIZED:
raise exceptions.UnauthorizedException("UNAUTHORIZED auth token")
elif response.status_code == HTTPStatus.FORBIDDEN:
data = response.json()
if data.get('code') == 'service_inactive':
raise exceptions.UserInactiveException(data.get('detail'))
elif response.status_code == HTTPStatus.NOT_FOUND:
raise exceptions.HttpNotFound(response.status_code)
elif not _status_success(response.status_code):
raise exceptions.HttpException(response.status_code)
return response
class JournalManager(BaseManager):
def __init__(self, remote, auth_token):
super().__init__(auth_token)
self.remote = furl(remote)
self.remote.path.segments.extend(API_PATH + ('journals', ''))
self.remote.path.normalize()
def list(self, password):
response = self.requests.get(self.remote.url)
self._validate_response(response)
data = response.json()
for j in data:
uid = j['uid']
version = j['version']
content = base64.b64decode(j['content'])
owner = j['owner']
key = j['key']
readOnly = j['readOnly']
last_uid = j.get('lastUid', None)
encrypted_key = base64.b64decode(key) if key is not None else None
crypto_manager = CryptoManager(version, password, uid.encode())
journal = RawJournal(crypto_manager=crypto_manager, content=content, uid=uid, owner=owner,
encrypted_key=encrypted_key, read_only=readOnly, remote_last_uid=last_uid)
yield journal
def add(self, journal):
data = journal.to_simple()
response = self.requests.post(self.remote.url, json=data)
self._validate_response(response)
def delete(self, journal):
remote = self.detail_url(journal.uid)
response = self.requests.delete(remote.url)
self._validate_response(response)
def update(self, journal):
remote = self.detail_url(journal.uid)
data = journal.to_simple()
response = self.requests.put(remote.url, json=data)
self._validate_response(response)
# Members
def _get_member_remote(self, journal, member_user=None):
remote = self.detail_url(journal.uid).copy()
segments = ['members']
if member_user is not None:
segments.append(member_user)
segments.append('')
remote.path.segments.extend(segments)
remote.path.normalize()
return remote
def member_add(self, journal, member):
remote = self._get_member_remote(journal)
data = member.to_simple()
response = self.requests.post(remote.url, json=data)
self._validate_response(response)
class EntryManager(BaseManager):
def __init__(self, remote, auth_token, journalId):
super().__init__(auth_token)
self.remote = furl(remote)
self.remote.path.segments.extend(API_PATH + ('journals', journalId, 'entries', ''))
self.remote.path.normalize()
def list(self, crypto_manager, last=None):
remote = self.remote.copy()
prev = None
if last is not None:
prev = RawEntry(crypto_manager, b'', last)
remote.args['last'] = last
response = self.requests.get(remote.url)
self._validate_response(response)
data = response.json()
for j in data:
uid = j['uid']
content = base64.b64decode(j['content'])
entry = RawEntry(crypto_manager=crypto_manager, content=content, uid=uid)
entry.verify(prev)
prev = entry
yield entry
def add(self, entries, last=None):
remote = self.remote.copy()
if last is not None:
remote.args['last'] = last
data = list(map(lambda x: x.to_simple(), entries))
response = self.requests.post(remote.url, json=data)
self._validate_response(response)
class UserInfoManager(BaseManager):
def __init__(self, remote, auth_token):
super().__init__(auth_token)
self.remote = furl(remote)
self.remote.path.segments.extend(API_PATH + ('user', ''))
self.remote.path.normalize()
def get(self, owner, cipher_key):
remote = self.detail_url(owner)
response = self.requests.get(remote.url)
self._validate_response(response)
data = response.json()
version = data['version']
content = base64.b64decode(data['content'])
pubkey = base64.b64decode(data['pubkey'])
crypto_manager = CryptoManager(version, cipher_key, b"userInfo")
return RawUserInfo(crypto_manager, owner, pubkey, content)
def add(self, user_info):
data = user_info.to_simple()
response = self.requests.post(self.remote.url, json=data)
self._validate_response(response)
def delete(self, user_info):
remote = self.detail_url(user_info.owner)
response = self.requests.delete(remote.url)
self._validate_response(response)
def update(self, user_info):
remote = self.detail_url(user_info.owner)
data = user_info.to_simple()
response = self.requests.put(remote.url, json=data)
self._validate_response(response)
class SyncEntry:
def __init__(self, action, content):
self.action = action
self.content = content
@classmethod
def from_json(cls, json_string):
data = json.loads(json_string)
return SyncEntry(data['action'], data['content'])
def to_json(self):
data = {'action': self.action, 'content': self.content}
return json.dumps(data, ensure_ascii=False)
class Member:
def __init__(self, user, key):
self.user = user
self.key = key
def to_simple(self):
key = base64.b64encode(self.key)
return {'user': self.user, 'key': key.decode()}
pyetesync-0.12.1/example.py 0000664 0000000 0000000 00000002603 13715155050 0015626 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
import sys
import etesync as api
def printJournal(entry):
print("UID {} (version: {})".format(entry.uid, entry.version))
print("CONTENT {}".format(entry.content))
print()
def printEntry(entry):
print("UID {}".format(entry.uid))
print("CONTENT {}".format(entry.content))
print()
try:
_, email, servicePassword, userPassword, remoteUrl = sys.argv
except ValueError:
sys.exit('Incorrect arguments!\nRequired: '
' ')
# Token should be saved intead of requested every time
authToken = api.Authenticator(remoteUrl).get_auth_token(email, servicePassword)
etesync = api.EteSync(email, authToken, remote=remoteUrl)
print("Deriving key")
# Very slow operation, should probably be securely cached
etesync.derive_key(userPassword)
print("Syncing")
etesync.sync()
print("Syncing done")
if len(sys.argv) == 6:
journal = etesync.get(sys.argv[5])
# Enable if you'd like to dump the journal
if False:
for entry in journal.list():
printEntry(entry)
# Or interact with the collection
print("Journal items: {}".format(len(list(journal.list()))))
print("Collection items: {}".format(len(list(journal.collection.list()))))
print("Collection: {}".format(list(journal.collection.list())))
else:
for entry in etesync.list():
printJournal(entry)
pyetesync-0.12.1/example_crud.py 0000664 0000000 0000000 00000006750 13715155050 0016652 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
import etesync as api
# The EtesyncCRUD class exposes methods for each of the CRUD operations
# (Create, Retrieve, Update and Delete) and for sync with the server.
# It handles only one calendar
# The class is initialized with user details, authToken and
# EITHER the encryption password OR the cipher key.
# Intended usage is that a calling program (a CLI) obtains user credentials
# from terminal input or some secure storage (like a key ring)
# and then creates an instance of EtesyncCRUD as follows:
# # call with cipher key
# crud = EtesyncCRUD(email, None, remoteUrl, uid, authToken, cipher_key)
# # call with encryption password
# crud = EtesyncCRUD(email, userPassword, remoteUrl, uid, authToken, None)
# The CLI program can then perform CRUD operations by calling
# crud.create_event, crud.retrieve_event,
# crud.update_event and crud.delete_event
# The CLI must explicitly call crud.sync when needed. For example:
# (a) if the server has been updated from another device
# (b) after any CRUD operation other than Retrieve
# No exception handling is done. That is left to the CLI.
class EtesyncCRUD:
def __init__(self, email, userPassword, remoteUrl, uid, authToken,
cipher_key=None):
"""Initialize
Parameters
----------
email : etesync username(email)
userPassword : etesync encryption password
remoteUrl : url of etesync server
uid : uid of calendar
authToken : authentication token for etesync server
"""
self.etesync = api.EteSync(email, authToken, remote=remoteUrl)
if cipher_key:
self.etesync.cipher_key = cipher_key
else:
self.etesync.derive_key(userPassword)
# needs to be done once on any machine
# else the get on the next line fails
silent or print("Syncing with server. Please wait")
self.etesync.sync()
silent or print("Syncing completed.")
self.journal = self.etesync.get(uid)
self.calendar = self.journal.collection
def create_event(self, event):
"""Create event
Parameters
----------
event : iCalendar file as a string
(calendar containing one event to be added)
"""
ev = api.Event.create(self.journal.collection, event)
ev.save()
def update_event(self, event, uid):
"""Edit event
Parameters
----------
event : iCalendar file as a string
(calendar containing one event to be updated)
uid : uid of event to be updated
"""
ev_for_change = self.calendar.get(uid)
ev_for_change.content = event
ev_for_change.save()
def retrieve_event(self, uid):
r"""Retrieve event by uid
Parameters
----------
uid : uid of event to be retrieved
Returns
-------
iCalendar file (as a string)
"""
return self.calendar.get(uid).content
def all_events(self):
"""Retrieve all events in calendar
Returns
-------
List of iCalendar files (as strings)
"""
return [e.content for e in self.calendar.list()]
def delete_event(self, uid):
"""Delete event and sync calendar
Parameters
----------
uid : uid of event to be deleted
"""
ev_for_deletion = self.calendar.get(uid)
ev_for_deletion.delete()
def sync(self):
r"""Sync with server
"""
self.etesync.sync()
pyetesync-0.12.1/icon.svg 0000664 0000000 0000000 00000017006 13715155050 0015275 0 ustar 00root root 0000000 0000000
image/svg+xml
pyetesync-0.12.1/requirements.in/ 0000775 0000000 0000000 00000000000 13715155050 0016750 5 ustar 00root root 0000000 0000000 pyetesync-0.12.1/requirements.in/development.txt 0000664 0000000 0000000 00000000051 13715155050 0022027 0 ustar 00root root 0000000 0000000 -r ../requirements.txt
pytest
pytest-cov
pyetesync-0.12.1/requirements.in/requirements-dev.txt 0000664 0000000 0000000 00000001532 13715155050 0023011 0 ustar 00root root 0000000 0000000 #
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file=requirements.in/requirements-dev.txt requirements.in/development.txt
#
appdirs==1.4.3
asn1crypto==0.24.0
atomicwrites==1.3.0 # via pytest
attrs==19.1.0
certifi==2019.6.16
cffi==1.12.3
chardet==3.0.4
coverage==4.5.4 # via pytest-cov
cryptography==2.7
furl==2.0.0
idna==2.8
importlib-metadata==0.19 # via pluggy, pytest
more-itertools==7.2.0 # via pytest
orderedmultidict==1.0.1
packaging==19.1
peewee==3.10.0
pluggy==0.12.0 # via pytest
py==1.8.0
pyasn1==0.4.6
pycparser==2.19
pyparsing==2.4.2
pyscrypt==1.6.2
pytest-cov==2.7.1
pytest==5.0.1
python-dateutil==2.8.0
pytz==2019.2
requests==2.22.0
six==1.12.0
urllib3==1.25.3
vobject==0.9.6.1
wcwidth==0.1.7 # via pytest
zipp==0.5.2 # via importlib-metadata
pyetesync-0.12.1/requirements.txt 0000664 0000000 0000000 00000002462 13715155050 0017110 0 ustar 00root root 0000000 0000000 #
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file=requirements.txt setup.py
#
appdirs==1.4.3 # via etesync (setup.py)
asn1crypto==0.24.0 # via etesync (setup.py)
attrs==19.1.0 # via packaging
certifi==2019.6.16 # via requests
cffi==1.12.3 # via cryptography, etesync (setup.py)
chardet==3.0.4 # via requests
cryptography==3.0 # via etesync (setup.py)
furl==2.0.0 # via etesync (setup.py)
idna==2.8 # via etesync (setup.py), requests
orderedmultidict==1.0.1 # via etesync (setup.py), furl
packaging==19.1 # via etesync (setup.py)
peewee==3.10.0 # via etesync (setup.py)
py==1.8.0 # via etesync (setup.py)
pyasn1==0.4.6 # via etesync (setup.py)
pycparser==2.19 # via cffi, etesync (setup.py)
pyparsing==2.4.2 # via etesync (setup.py), packaging
python-dateutil==2.8.0 # via etesync (setup.py), vobject
pytz==2019.2 # via etesync (setup.py)
requests==2.22.0 # via etesync (setup.py)
six==1.12.0 # via cryptography, etesync (setup.py), furl, orderedmultidict, packaging, python-dateutil
urllib3==1.25.3 # via requests
vobject==0.9.6.1 # via etesync (setup.py)
pyetesync-0.12.1/run_all_tests.sh 0000775 0000000 0000000 00000000054 13715155050 0017034 0 ustar 00root root 0000000 0000000 #!/bin/bash
EXTENDED_TESTING=1 pytest "$@"
pyetesync-0.12.1/run_unit_tests.sh 0000775 0000000 0000000 00000000031 13715155050 0017236 0 ustar 00root root 0000000 0000000 #!/bin/bash
pytest "$@"
pyetesync-0.12.1/setenv 0000664 0000000 0000000 00000000032 13715155050 0015042 0 ustar 00root root 0000000 0000000 source .venv/bin/activate
pyetesync-0.12.1/setup.py 0000664 0000000 0000000 00000002055 13715155050 0015334 0 ustar 00root root 0000000 0000000 # -*- coding: utf-8 -*-
from setuptools import find_packages, setup
exec(open('etesync/_version.py').read())
setup(
name='etesync',
version=__version__,
author='Tom Hacohen',
author_email='tom@stosb.com',
url='https://github.com/etesync/pyetesync',
description='Python client library for EteSync',
keywords=['etesync', 'encryption', 'sync', 'pim'],
license='LGPL-3.0-only',
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
packages=find_packages(exclude=("tests",)),
include_package_data=True,
install_requires=[
'appdirs>=1.4',
'asn1crypto>=0.22',
'cffi>=1.10',
'cryptography>=3.0',
'furl>=0.5',
'idna>=2.5',
'orderedmultidict>=0.7',
'packaging>=16.8',
'peewee>=3.7.0',
'py>=1.4',
'pyasn1>=0.2',
'pycparser>=2.17',
'pyparsing>=2.2',
'python-dateutil>=2.6',
'pytz>=2019.1',
'requests>=2.21',
'six>=1.10',
'vobject>=0.9',
]
)
pyetesync-0.12.1/tests/ 0000775 0000000 0000000 00000000000 13715155050 0014762 5 ustar 00root root 0000000 0000000 pyetesync-0.12.1/tests/__init__.py 0000664 0000000 0000000 00000001143 13715155050 0017072 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
pyetesync-0.12.1/tests/test_collections.py 0000664 0000000 0000000 00000015403 13715155050 0020714 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import pytest
import binascii
import etesync as api
from etesync import exceptions
from etesync.crypto import hmac256
USER_EMAIL = 'test@localhost'
TEST_REMOTE = 'http://localhost:8000'
TEST_DB = ':memory:'
# Gets fake (consistent between tests) random numbers
def get_random_uid(context):
context._rand = getattr(context, '_rand', 0)
context._rand = context._rand + 1
return binascii.hexlify(hmac256(b'', str(context._rand).encode())).decode()
@pytest.fixture(scope="module")
def etesync():
return api.EteSync(USER_EMAIL, '', remote=TEST_REMOTE, db_path=TEST_DB)
class TestCollection:
@pytest.fixture(autouse=True)
def transact(self, request, etesync):
etesync._init_db(TEST_DB)
yield
def test_crud(self, etesync):
# Empty collections
assert len(list(etesync.list())) == 0
# Create
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test', 'description': 'Test desc'})
b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'})
assert a is not None
assert b is not None
# Description is what we expect
assert a.description == 'Test desc'
# Still empty because we haven't saved
assert len(list(etesync.list())) == 0
# Fetch before saved:
with pytest.raises(exceptions.DoesNotExist):
etesync.get(a.journal.uid)
a.save()
assert 'Test' == list(etesync.list())[0].collection.display_name
assert len(list(etesync.list())) == 1
b.save()
assert len(list(etesync.list())) == 2
# Get
assert a.journal.uid == etesync.get(a.journal.uid).uid
assert b.journal.uid == etesync.get(b.journal.uid).uid
# Check version is correct
assert a.journal.version > 0
# Delete
a.delete()
assert len(list(etesync.list())) == 1
b.delete()
assert len(list(etesync.list())) == 0
# Try saving two collections with the same uid
c = api.Calendar.create(etesync, a.journal.uid, {'displayName': 'Test'})
with pytest.raises(exceptions.AlreadyExists):
c.save()
def test_content_crud(self, etesync):
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'})
b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'})
c = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test 3'})
ev = api.Event.create(a,
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')
# Try saving before the journal is saved
with pytest.raises(exceptions.DoesNotExist):
ev.save()
# Create the event
a.save()
ev.save()
assert ev.uid == a.get('2cd64f22-1111-44f5-bc45-53440af38cec').uid
# Fail to create another event with the same uid
ev = api.Event.create(a,
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')
with pytest.raises(exceptions.AlreadyExists):
ev.save()
# Trying to add an Event into an AddressBook
b.save()
# Wrong child in collection
with pytest.raises(exceptions.TypeMismatch):
api.Event.create(b, (
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nUID:2cd64f22-2222-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n'))
# Same uid in different collections
c.save()
ev2 = api.Event.create(c, ev.content)
ev2.save()
# Check it's actually there
assert len(list(c.list())) == 1
# # First is still here even after we delete the new one
ev2.delete()
assert len(list(c.list())) == 0
assert ev.uid == a.get('2cd64f22-1111-44f5-bc45-53440af38cec').uid
# Check fetching a non-existent item
with pytest.raises(exceptions.DoesNotExist):
c.get('bla')
# Fail creating an event without a uid.
ev = api.Event.create(a,
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')
assert ev is None
def test_unicode(self, etesync):
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'יוניקוד'})
# Create the event
a.save()
a2 = etesync.get(a.journal.uid)
assert a.display_name == a2.collection.display_name
ev = api.Event.create(a, (
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nUID:2cd64f22-2222-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:יוניקוד\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n'))
ev.save()
assert ev.content == a.get(ev.uid).content
# Test repr works
repr(ev)
pyetesync-0.12.1/tests/test_crypto.py 0000664 0000000 0000000 00000007142 13715155050 0017717 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import pytest
from etesync import crypto, exceptions
USER_EMAIL = 'test@localhost'
USER_PASSWORD = 'SomePassword'
TEST_REMOTE = 'http://localhost:8000'
TEST_DB = ':memory:'
# Derived key
DERIVED_KEY = (b'\x1a\x99\xfa\x8f\xa5\x89\xff\xd2ImY\x16\x86a\x1ff9jJ\x9b9\xaf\x01\x0e\xce5\x0e;J\xea\xb9\xfb' +
b'\xdb\xe2\xfbS\xe1G\xd1\x83\x1d.2+\xee\x1b\x08\xc5\xef\xff\x18\xd7=`\x94\x80\x12_\xb3\xb3\xff' +
b'\x89v\x8e\xe7 \x7f\xe9@\xce\r\xa8M\x91hui\x17E\x90\x83\x98V\xbbs\xd6\xbc\xffN1"\xce\xe4\xa0Y' +
b'\x94\x1f~\x11\x97\xd5\xd7[\xa5v\xfc`\xd5\x8a\x04!AO.Zz\xc6\xa3\xbfy\xb6\xeah5\x03\x1b\xb4\x1f' +
b'\x11\x80\xc5\xf9\x1b+\xb1G\x9eE\xbd\xcc\x9b\x1e\x96\x01o\n\xc3"\xc2\xaf]\xc0qXQ,q\xd3T\xf3<\x9e' +
b'\xab|\xa3\xbc:?[\xfc\xb6\xe1\xd0)[y\xa76\x8e\xef\xb3\n\x0b\xda\xbadf=\xdd\xab')
class TestCrypto:
def test_derive(self):
# Just make sure we don't break derivation
key = crypto.derive_key(USER_PASSWORD, USER_EMAIL)
assert key == DERIVED_KEY
def test_crypto_v1(self):
# Just make sure we don't break derivation
crypto_manager = crypto.CryptoManager(1, DERIVED_KEY, b'TestSaltShouldBeJournalId')
clear_text = b'This Is Some Test Cleartext.'
cipher = crypto_manager.encrypt(clear_text)
assert clear_text == crypto_manager.decrypt(cipher)
expected = b'/?\x87P\\\xe1\xd4wc\xc6\xe1\x9dB\xb0p\x04mH\xcct\xf4\xba\x0e\xa6;\xc7\xf0x\xf4\x9b^\xd7'
assert expected == crypto_manager.hmac(b'Some test data')
def test_crypto_v2(self):
# Just make sure we don't break derivation
crypto_manager = crypto.CryptoManager(2, DERIVED_KEY, b'TestSaltShouldBeJournalId')
clear_text = b'This Is Some Test Cleartext.'
cipher = crypto_manager.encrypt(clear_text)
assert clear_text == crypto_manager.decrypt(cipher)
expected = (b']\x0f\xc0\xd2\x07\xa7\xb4\xe6\x84\xf7\xc4}\xc37\xf7\xccB\x00\x1e>\x0e\x1fQ\x85\xf0\x9e\x02\xe8' +
b'\x98\x89\xba\x9a')
assert expected == crypto_manager.hmac(b'Some test data')
def test_asymmetric_crypto(self):
key_pair = crypto.AsymmetricCryptoManager.generate_key_pair()
asymmetric_crypto_manager = crypto.AsymmetricCryptoManager(key_pair)
encrypted_key = asymmetric_crypto_manager.encrypt(key_pair.public_key, DERIVED_KEY)
decrypted_key = asymmetric_crypto_manager.decrypt(encrypted_key)
assert DERIVED_KEY == decrypted_key
crypto_manager = crypto.CryptoManager(2, DERIVED_KEY, b'TestSaltShouldBeJournalId')
clear_text = b'This Is Some Test Cleartext.'
cipher = (b'\x109\xc3_\x1dM\xcd\xcf\x0e>_\xcb\x10\xff7\x07\xe3\xc6/\x17Y\x94} \x04\x1f\x11g\xa3\x1e\x11\xe5' +
b'\xfe#\xbb]JZm\x1dk\xb2\x97\xde\xfcdo\xd3')
assert clear_text == crypto_manager.decrypt(cipher)
def test_crypto_v_too_new(self):
with pytest.raises(exceptions.VersionTooNew):
crypto.CryptoManager(293, DERIVED_KEY, b'TestSalt')
pyetesync-0.12.1/tests/test_service.py 0000664 0000000 0000000 00000027631 13715155050 0020044 0 ustar 00root root 0000000 0000000 # Copyright © 2017 Tom Hacohen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, version 3.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import pytest
import binascii
import requests
import json
import os
import etesync as api
from etesync import exceptions
from etesync.crypto import hmac256
# Not public API, used for verification
from etesync.service import EntryManager, RawJournal, CryptoManager
USER_EMAIL = 'test@localhost'
USER_PASSWORD = 'SomePassword'
USER2_EMAIL = 'test2@localhost'
TEST_REMOTE = 'http://localhost:8000/'
TEST_DB = ':memory:'
WANT_INTEGRATION_TESTS = bool(os.environ.get('EXTENDED_TESTING', False))
# Gets fake (consistent between tests) random numbers
def get_random_uid(context):
context._rand = getattr(context, '_rand', 0)
context._rand = context._rand + 1
return binascii.hexlify(hmac256(b'', str(context._rand).encode())).decode()
def get_action(entry):
return json.loads(entry._cache_obj.content).get('action')
@pytest.fixture(scope="module")
def etesync():
auth = api.Authenticator(TEST_REMOTE)
token = auth.get_auth_token(USER_EMAIL, USER_PASSWORD)
return api.EteSync(USER_EMAIL, token, remote=TEST_REMOTE, db_path=TEST_DB)
@pytest.mark.skipif(not WANT_INTEGRATION_TESTS, reason='Skipping itegration tests because EXTENDED_TESTING env var is unset.')
class TestService:
@pytest.fixture(autouse=True)
def transact(self, request, etesync):
# Clear the db for this user
headers = {'Authorization': 'Token ' + etesync.auth_token}
response = requests.post(TEST_REMOTE + 'reset/', headers=headers, allow_redirects=False)
assert response.status_code == 200
etesync._init_db(TEST_DB)
yield
def test_auth_token(self):
auth = api.Authenticator(TEST_REMOTE)
token = auth.get_auth_token(USER_EMAIL, USER_PASSWORD)
assert len(token) > 0
with pytest.raises(exceptions.UnauthorizedException):
token = auth.get_auth_token(USER_EMAIL, 'BadPassword')
def test_sync_simple(self, etesync):
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'})
b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'})
a.save()
b.save()
assert len(list(etesync.list())) == 2
# Make sure we detect dirty correctly
assert etesync.journal_list_is_dirty()
assert not etesync.journal_is_dirty(a.journal.uid)
etesync.sync()
# Make sure they are not dirty anymore
assert not etesync.journal_list_is_dirty()
assert not etesync.journal_is_dirty(a.journal.uid)
# Reset the db
etesync._init_db(TEST_DB)
assert len(list(etesync.list())) == 0
etesync.sync()
assert len(list(etesync.list())) == 2
a = etesync.get(a.journal.uid).collection
b = etesync.get(b.journal.uid).collection
assert a.display_name == 'Test'
a.update_info({'displayName': 'Test Update'})
a.save()
b.delete()
etesync.sync()
with pytest.raises(RuntimeError):
# Hackily try and update the Journal info's directly
a.journal.update_info(None)
# Reset the db
etesync._init_db(TEST_DB)
assert len(list(etesync.list())) == 0
etesync.sync()
assert len(list(etesync.list())) == 1
a = etesync.get(a.journal.uid).collection
assert a.display_name == 'Test Update'
def test_collection_delete_server(self, etesync):
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'})
b = api.AddressBook.create(etesync, get_random_uid(self), {'displayName': 'Test 2'})
a.save()
b.save()
assert len(list(etesync.list())) == 2
etesync.sync()
# Reset the db
etesync._init_db(TEST_DB)
assert len(list(etesync.list())) == 0
etesync.sync()
assert len(list(etesync.list())) == 2
b.delete()
etesync.sync()
# Reset the db
etesync._init_db(TEST_DB)
assert len(list(etesync.list())) == 0
etesync.sync()
assert len(list(etesync.list())) == 1
def test_collection_journal(self, etesync):
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'})
a.save()
assert len(list(etesync.list())) == 1
# A journal is only dirty if content is dirty
assert not etesync.journal_is_dirty(a.journal.uid)
ev = api.Event.create(a,
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:FÖÖBÖÖ\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')
ev.save()
# We have new content, make sure journal is marked as dirty
assert etesync.journal_is_dirty(a.journal.uid)
etesync.sync()
# We just synced, not dirty anymore.
assert not etesync.journal_is_dirty(a.journal.uid)
ev = a.get(ev.uid)
assert len(list(a.journal.list())) == 1
assert get_action(list(a.journal.list())[-1]) == 'ADD'
ev.content = ev.content + ' '
ev.save()
etesync.sync()
ev = a.get(ev.uid)
assert len(list(a.journal.list())) == 2
assert get_action(list(a.journal.list())[-1]) == 'CHANGE'
ev.delete()
etesync.sync()
assert len(list(a.journal.list())) == 3
assert get_action(list(a.journal.list())[-1]) == 'DELETE'
# Reset db
etesync._init_db(TEST_DB)
etesync.sync()
assert len(list(a.journal.list())) == 3
assert get_action(list(a.journal.list())[-1]) == 'DELETE'
assert len(list(a.list())) == 0
def test_collection_unicode(self, etesync):
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'fööböö'})
a.save()
ev = api.Event.create(
a,
('BEGIN:VCALENDAR\r\n'
'BEGIN:VEVENT\r\n'
'UID:test @ foo ät bar град сатану\r\n'
'SUMMARY:FÖÖBÖÖ\r\n'
'END:VEVENT\r\n'
'END:VCALENDAR\r\n')
)
ev.save()
etesync.sync()
def test_collection_shared(self, etesync):
from etesync import service, crypto
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'fööböö'})
a.save()
ev = api.Event.create(
a,
('BEGIN:VCALENDAR\r\n'
'BEGIN:VEVENT\r\n'
'UID:test @ foo ät bar град сатану\r\n'
'SUMMARY:FÖÖBÖÖ\r\n'
'END:VEVENT\r\n'
'END:VCALENDAR\r\n')
)
ev.save()
journal_manager = service.JournalManager(etesync.remote, etesync.auth_token)
etesync.sync()
# Second user
auth = api.Authenticator(TEST_REMOTE)
token = auth.get_auth_token(USER2_EMAIL, USER_PASSWORD)
etesync2 = api.EteSync(USER2_EMAIL, token, remote=TEST_REMOTE, db_path=TEST_DB)
headers = {'Authorization': 'Token ' + etesync2.auth_token}
response = requests.post(TEST_REMOTE + 'reset/', headers=headers, allow_redirects=False)
assert response.status_code == 200
user_info = etesync2.get_or_create_user_info()
key_pair = crypto.AsymmetricKeyPair(user_info.content, user_info.pubkey)
asymmetric_crypto_manager = crypto.AsymmetricCryptoManager(key_pair)
cipher_key = hmac256(a.journal.uid.encode(), etesync.cipher_key)
encrypted_key = asymmetric_crypto_manager.encrypt(key_pair.public_key, cipher_key)
member = service.Member(USER2_EMAIL, encrypted_key)
journal_manager.member_add(a.journal._cache_obj, member)
etesync2.sync()
journal_list = list(etesync2.list())
assert len(journal_list) == 1
assert journal_list[0].uid == a.journal.uid
def test_user_info_manage(self, etesync):
# FIXME: Shouldn't expose and rely on service
from etesync import service
from etesync.crypto import CryptoManager, CURRENT_VERSION
# Failed get
info_manager = service.UserInfoManager(etesync.remote, etesync.auth_token)
with pytest.raises(exceptions.HttpException):
info_manager.get(USER_EMAIL, etesync.cipher_key)
# Add
crypto_manager = CryptoManager(CURRENT_VERSION, etesync.cipher_key, b"userInfo")
user_info = service.RawUserInfo(crypto_manager, USER_EMAIL, b"pubkeyTest")
user_info.update(b"contentTest")
user_info.verify()
info_manager.add(user_info)
user_info2 = info_manager.get(USER_EMAIL, etesync.cipher_key)
user_info2.verify()
assert user_info.content == user_info2.content
assert user_info.pubkey == user_info2.pubkey
assert user_info.owner == user_info2.owner
# Update
user_info.update(b"contentTest2")
info_manager.update(user_info)
user_info2 = info_manager.get(USER_EMAIL, etesync.cipher_key)
user_info2.verify()
assert user_info.content == user_info2.content
assert user_info.pubkey == user_info2.pubkey
assert user_info.owner == user_info2.owner
# Delete
info_manager.delete(user_info)
with pytest.raises(exceptions.HttpException):
info_manager.get(USER_EMAIL, etesync.cipher_key)
def test_collection_sync(self, etesync):
a = api.Calendar.create(etesync, get_random_uid(self), {'displayName': 'Test'})
ev = api.Event.create(a,
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nUID:2cd64f22-1111-44f5-bc45-53440af38cec\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed cat\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')
# Create the event
a.save()
ev.save()
# Add another and then sync (check we can sync more than one)
ev = api.Event.create(a,
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:+//Yo\r\nBEGIN:VEVENT\r\nDTSTAMP:20170324T164' +
'747Z\r\nUID:2cd64f22-1111-44f5-bc45-aaaaaaaaaaac\r\nDTSTART;VALUE\u003dDATE:20170324' +
'\r\nDTEND;VALUE\u003dDATE:20170325\r\nSUMMARY:Feed 2\r\nSTATUS:CONFIRMED\r\nTRANSP:' +
'TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n')
ev.save()
assert len(list(a.list())) == 2
etesync.sync()
ev.delete()
assert len(list(a.list())) == 1
etesync.sync()
# Verify we created valid journal entries
journal_uid = a.journal.uid
manager = EntryManager(etesync.remote, etesync.auth_token, journal_uid)
crypto_manager = CryptoManager(a.journal.version, etesync.cipher_key, journal_uid.encode())
journal = RawJournal(crypto_manager, uid=journal_uid)
crypto_manager = etesync._get_journal_cryptomanager(journal)
prev = None
last_uid = None
for entry in manager.list(crypto_manager, last_uid):
entry.verify(prev)
prev = entry