diff options
Diffstat (limited to 'soledad/backends')
-rw-r--r-- | soledad/backends/__init__.py | 9 | ||||
-rw-r--r-- | soledad/backends/couch.py | 270 | ||||
-rw-r--r-- | soledad/backends/leap_backend.py | 224 | ||||
-rw-r--r-- | soledad/backends/objectstore.py | 135 | ||||
-rw-r--r-- | soledad/backends/sqlcipher.py | 163 |
5 files changed, 801 insertions, 0 deletions
diff --git a/soledad/backends/__init__.py b/soledad/backends/__init__.py new file mode 100644 index 00000000..61438e8a --- /dev/null +++ b/soledad/backends/__init__.py @@ -0,0 +1,9 @@ +""" +Backends that extend U1DB functionality. +""" + +import objectstore + + +__all__ = [ + 'objectstore'] diff --git a/soledad/backends/couch.py b/soledad/backends/couch.py new file mode 100644 index 00000000..b7a77054 --- /dev/null +++ b/soledad/backends/couch.py @@ -0,0 +1,270 @@ +"""A U1DB backend that uses CouchDB as its persistence layer.""" + +# general imports +import uuid +from base64 import b64encode, b64decode +import re +# u1db +from u1db import errors +from u1db.sync import LocalSyncTarget +from u1db.backends.inmemory import InMemoryIndex +from u1db.remote.server_state import ServerState +from u1db.errors import DatabaseDoesNotExist +# couchdb +from couchdb.client import Server, Document as CouchDocument +from couchdb.http import ResourceNotFound +# leap +from leap.soledad.backends.objectstore import ( + ObjectStoreDatabase, + ObjectStoreSyncTarget, +) +from leap.soledad.backends.leap_backend import LeapDocument + +try: + import simplejson as json +except ImportError: + import json # noqa + + +class InvalidURLError(Exception): + """Exception raised when Soledad encounters a malformed URL.""" + pass + + +class CouchDatabase(ObjectStoreDatabase): + """A U1DB backend that uses Couch as its persistence layer.""" + + @classmethod + def open_database(cls, url, create): + """Open a U1DB database using CouchDB as backend.""" + # get database from url + m = re.match('(^https?://[^/]+)/(.+)$', url) + if not m: + raise InvalidURLError + url = m.group(1) + dbname = m.group(2) + server = Server(url=url) + try: + server[dbname] + except ResourceNotFound: + if not create: + raise DatabaseDoesNotExist() + return cls(url, dbname) + + def __init__(self, url, database, replica_uid=None, full_commit=True, + session=None): + """Create a new Couch data container.""" + self._url = url + self._full_commit = full_commit + self._session = session + self._server = Server(url=self._url, + full_commit=self._full_commit, + session=self._session) + self._dbname = database + # this will ensure that transaction and sync logs exist and are + # up-to-date. + try: + self._database = self._server[database] + except ResourceNotFound: + self._server.create(database) + self._database = self._server[database] + super(CouchDatabase, self).__init__(replica_uid=replica_uid, + document_factory=LeapDocument) + + #------------------------------------------------------------------------- + # methods from Database + #------------------------------------------------------------------------- + + def _get_doc(self, doc_id, check_for_conflicts=False): + """Get just the document content, without fancy handling.""" + cdoc = self._database.get(doc_id) + if cdoc is None: + return None + has_conflicts = False + if check_for_conflicts: + has_conflicts = self._has_conflicts(doc_id) + doc = self._factory( + doc_id=doc_id, + rev=cdoc['u1db_rev'], + has_conflicts=has_conflicts) + contents = self._database.get_attachment(cdoc, 'u1db_json') + if contents: + doc.content = json.loads(contents.getvalue()) + else: + doc.make_tombstone() + return doc + + def get_all_docs(self, include_deleted=False): + """Get the JSON content for all documents in the database.""" + generation = self._get_generation() + results = [] + for doc_id in self._database: + if doc_id == self.U1DB_DATA_DOC_ID: + continue + doc = self._get_doc(doc_id, check_for_conflicts=True) + if doc.content is None and not include_deleted: + continue + results.append(doc) + return (generation, results) + + def _put_doc(self, doc): + """Store document in database.""" + # prepare couch's Document + cdoc = CouchDocument() + cdoc['_id'] = doc.doc_id + # we have to guarantee that couch's _rev is cosistent + old_cdoc = self._database.get(doc.doc_id) + if old_cdoc is not None: + cdoc['_rev'] = old_cdoc['_rev'] + # store u1db's rev + cdoc['u1db_rev'] = doc.rev + # save doc in db + self._database.save(cdoc) + # store u1db's content as json string + if not doc.is_tombstone(): + self._database.put_attachment(cdoc, doc.get_json(), + filename='u1db_json') + else: + self._database.delete_attachment(cdoc, 'u1db_json') + + def get_sync_target(self): + """ + Return a SyncTarget object, for another u1db to synchronize with. + """ + return CouchSyncTarget(self) + + def create_index(self, index_name, *index_expressions): + """ + Create a named index, which can then be queried for future lookups. + """ + if index_name in self._indexes: + if self._indexes[index_name]._definition == list( + index_expressions): + return + raise errors.IndexNameTakenError + index = InMemoryIndex(index_name, list(index_expressions)) + for doc_id in self._database: + if doc_id == self.U1DB_DATA_DOC_ID: + continue + doc = self._get_doc(doc_id) + if doc.content is not None: + index.add_json(doc_id, doc.get_json()) + self._indexes[index_name] = index + # save data in object store + self._store_u1db_data() + + def close(self): + """Release any resources associated with this database.""" + # TODO: fix this method so the connection is properly closed and + # test_close (+tearDown, which deletes the db) works without problems. + self._url = None + self._full_commit = None + self._session = None + #self._server = None + self._database = None + return True + + def sync(self, url, creds=None, autocreate=True): + """Synchronize documents with remote replica exposed at url.""" + from u1db.sync import Synchronizer + return Synchronizer(self, CouchSyncTarget(url, creds=creds)).sync( + autocreate=autocreate) + + #------------------------------------------------------------------------- + # methods from ObjectStoreDatabase + #------------------------------------------------------------------------- + + def _init_u1db_data(self): + if self._replica_uid is None: + self._replica_uid = uuid.uuid4().hex + doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID) + doc.content = {'transaction_log': [], + 'conflicts': b64encode(json.dumps({})), + 'other_generations': {}, + 'indexes': b64encode(json.dumps({})), + 'replica_uid': self._replica_uid} + self._put_doc(doc) + + def _fetch_u1db_data(self): + # retrieve u1db data from couch db + cdoc = self._database.get(self.U1DB_DATA_DOC_ID) + jsonstr = self._database.get_attachment(cdoc, 'u1db_json').getvalue() + content = json.loads(jsonstr) + # set u1db database info + #self._sync_log = content['sync_log'] + self._transaction_log = content['transaction_log'] + self._conflicts = json.loads(b64decode(content['conflicts'])) + self._other_generations = content['other_generations'] + self._indexes = self._load_indexes_from_json( + b64decode(content['indexes'])) + self._replica_uid = content['replica_uid'] + # save couch _rev + self._couch_rev = cdoc['_rev'] + + def _store_u1db_data(self): + doc = self._factory(doc_id=self.U1DB_DATA_DOC_ID) + doc.content = { + 'transaction_log': self._transaction_log, + # Here, the b64 encode ensures that document content + # does not cause strange behaviour in couchdb because + # of encoding. + 'conflicts': b64encode(json.dumps(self._conflicts)), + 'other_generations': self._other_generations, + 'indexes': b64encode(self._dump_indexes_as_json()), + 'replica_uid': self._replica_uid, + '_rev': self._couch_rev} + self._put_doc(doc) + + #------------------------------------------------------------------------- + # Couch specific methods + #------------------------------------------------------------------------- + + def delete_database(self): + """Delete a U1DB CouchDB database.""" + del(self._server[self._dbname]) + + def _dump_indexes_as_json(self): + indexes = {} + for name, idx in self._indexes.iteritems(): + indexes[name] = {} + for attr in ['name', 'definition', 'values']: + indexes[name][attr] = getattr(idx, '_' + attr) + return json.dumps(indexes) + + def _load_indexes_from_json(self, indexes): + dict = {} + for name, idx_dict in json.loads(indexes).iteritems(): + idx = InMemoryIndex(name, idx_dict['definition']) + idx._values = idx_dict['values'] + dict[name] = idx + return dict + + +class CouchSyncTarget(ObjectStoreSyncTarget): + pass + + +class CouchServerState(ServerState): + """Inteface of the WSGI server with the CouchDB backend.""" + + def __init__(self, couch_url): + self.couch_url = couch_url + + def open_database(self, dbname): + """Open a database at the given location.""" + # TODO: open couch + from leap.soledad.backends.couch import CouchDatabase + return CouchDatabase.open_database(self.couch_url + '/' + dbname, + create=False) + + def ensure_database(self, dbname): + """Ensure database at the given location.""" + from leap.soledad.backends.couch import CouchDatabase + db = CouchDatabase.open_database(self.couch_url + '/' + dbname, + create=True) + return db, db._replica_uid + + def delete_database(self, dbname): + """Delete database at the given location.""" + from leap.soledad.backends.couch import CouchDatabase + CouchDatabase.delete_database(self.couch_url + '/' + dbname) diff --git a/soledad/backends/leap_backend.py b/soledad/backends/leap_backend.py new file mode 100644 index 00000000..a37f9d25 --- /dev/null +++ b/soledad/backends/leap_backend.py @@ -0,0 +1,224 @@ +""" +A U1DB backend that encrypts data before sending to server and decrypts after +receiving. +""" + +try: + import simplejson as json +except ImportError: + import json # noqa + +from u1db import Document +from u1db.remote import utils +from u1db.remote.http_target import HTTPSyncTarget +from u1db.remote.http_database import HTTPDatabase +from u1db.errors import BrokenSyncStream + +import uuid + + +class NoDefaultKey(Exception): + """ + Exception to signal that there's no default OpenPGP key configured. + """ + pass + + +class NoSoledadInstance(Exception): + """ + Exception to signal that no Soledad instance was found. + """ + pass + + +class DocumentNotEncrypted(Exception): + """ + Exception to signal failures in document encryption. + """ + pass + + +class LeapDocument(Document): + """ + Encryptable and syncable document. + + LEAP Documents are standard u1db documents with cabability of returning an + encrypted version of the document json string as well as setting document + content based on an encrypted version of json string. + """ + + def __init__(self, doc_id=None, rev=None, json='{}', has_conflicts=False, + encrypted_json=None, soledad=None, syncable=True): + super(LeapDocument, self).__init__(doc_id, rev, json, has_conflicts) + self._soledad = soledad + self._syncable = syncable + if encrypted_json: + self.set_encrypted_json(encrypted_json) + + def get_encrypted_content(self): + """ + Return an encrypted JSON serialization of document's contents. + """ + if not self._soledad: + raise NoSoledadInstance() + return self._soledad.encrypt_symmetric(self.doc_id, + self.get_json()) + + def set_encrypted_content(self, cyphertext): + """ + Set document's content based on an encrypted JSON serialization of + contents. + """ + plaintext = self._soledad.decrypt_symmetric(self.doc_id, cyphertext) + return self.set_json(plaintext) + + def get_encrypted_json(self): + """ + Return a valid JSON string containing document's content encrypted to + the user's public key. + """ + return json.dumps({'_encrypted_json': self.get_encrypted_content()}) + + def set_encrypted_json(self, encrypted_json): + """ + Set document's content based on a valid JSON string containing the + encrypted document's contents. + """ + if not self._soledad: + raise NoSoledadInstance() + cyphertext = json.loads(encrypted_json)['_encrypted_json'] + self.set_encrypted_content(cyphertext) + + def _get_syncable(self): + return self._syncable + + def _set_syncable(self, syncable=True): + self._syncable = syncable + + syncable = property( + _get_syncable, + _set_syncable, + doc="Determine if document should be synced with server." + ) + + # Returning the revision as string solves the following exception in + # Twisted web: + # exceptions.TypeError: Can only pass-through bytes on Python 2 + def _get_rev(self): + if self._rev is None: + return None + return str(self._rev) + + def _set_rev(self, rev): + self._rev = rev + + rev = property( + _get_rev, + _set_rev, + doc="Wrapper to ensure `doc.rev` is always returned as bytes.") + + +class LeapSyncTarget(HTTPSyncTarget): + """ + A SyncTarget that encrypts data before sending and decrypts data after + receiving. + """ + + def __init__(self, url, creds=None, soledad=None): + super(LeapSyncTarget, self).__init__(url, creds) + self._soledad = soledad + + def _parse_sync_stream(self, data, return_doc_cb, ensure_callback=None): + """ + Does the same as parent's method but ensures incoming content will be + decrypted. + """ + parts = data.splitlines() # one at a time + if not parts or parts[0] != '[': + raise BrokenSyncStream + data = parts[1:-1] + comma = False + if data: + line, comma = utils.check_and_strip_comma(data[0]) + res = json.loads(line) + if ensure_callback and 'replica_uid' in res: + ensure_callback(res['replica_uid']) + for entry in data[1:]: + if not comma: # missing in between comma + raise BrokenSyncStream + line, comma = utils.check_and_strip_comma(entry) + entry = json.loads(line) + # decrypt after receiving from server. + if not self._soledad: + raise NoSoledadInstance() + enc_json = json.loads(entry['content'])['_encrypted_json'] + if not self._soledad.is_encrypted_sym(enc_json): + raise DocumentNotEncrypted( + "Incoming document from sync is not encrypted.") + doc = LeapDocument(entry['id'], entry['rev'], + encrypted_json=entry['content'], + soledad=self._soledad) + return_doc_cb(doc, entry['gen'], entry['trans_id']) + if parts[-1] != ']': + try: + partdic = json.loads(parts[-1]) + except ValueError: + pass + else: + if isinstance(partdic, dict): + self._error(partdic) + raise BrokenSyncStream + if not data or comma: # no entries or bad extra comma + raise BrokenSyncStream + return res + + def sync_exchange(self, docs_by_generations, source_replica_uid, + last_known_generation, last_known_trans_id, + return_doc_cb, ensure_callback=None): + """ + Does the same as parent's method but encrypts content before syncing. + """ + self._ensure_connection() + if self._trace_hook: # for tests + self._trace_hook('sync_exchange') + url = '%s/sync-from/%s' % (self._url.path, source_replica_uid) + self._conn.putrequest('POST', url) + self._conn.putheader('content-type', 'application/x-u1db-sync-stream') + for header_name, header_value in self._sign_request('POST', url, {}): + self._conn.putheader(header_name, header_value) + entries = ['['] + size = 1 + + def prepare(**dic): + entry = comma + '\r\n' + json.dumps(dic) + entries.append(entry) + return len(entry) + + comma = '' + size += prepare( + last_known_generation=last_known_generation, + last_known_trans_id=last_known_trans_id, + ensure=ensure_callback is not None) + comma = ',' + for doc, gen, trans_id in docs_by_generations: + if doc.syncable: + # encrypt and verify before sending to server. + enc_json = json.loads( + doc.get_encrypted_json())['_encrypted_json'] + if not self._soledad.is_encrypted_sym(enc_json): + raise DocumentNotEncrypted( + "Could not encrypt document before sync.") + size += prepare(id=doc.doc_id, rev=doc.rev, + content=doc.get_encrypted_json(), + gen=gen, trans_id=trans_id) + entries.append('\r\n]') + size += len(entries[-1]) + self._conn.putheader('content-length', str(size)) + self._conn.endheaders() + for entry in entries: + self._conn.send(entry) + entries = None + data, _ = self._response() + res = self._parse_sync_stream(data, return_doc_cb, ensure_callback) + data = None + return res['new_generation'], res['new_transaction_id'] diff --git a/soledad/backends/objectstore.py b/soledad/backends/objectstore.py new file mode 100644 index 00000000..7c5d1177 --- /dev/null +++ b/soledad/backends/objectstore.py @@ -0,0 +1,135 @@ +""" +Abstract U1DB backend to handle storage using object stores (like CouchDB, for +example. + +Right now, this is only used by CouchDatabase backend, but can also be +extended to implement OpenStack or Amazon S3 storage, for example. +""" + +from u1db.backends.inmemory import ( + InMemoryDatabase, + InMemorySyncTarget, +) +from u1db import errors + + +class ObjectStoreDatabase(InMemoryDatabase): + """ + A backend for storing u1db data in an object store. + """ + + @classmethod + def open_database(cls, url, create, document_factory=None): + raise NotImplementedError(cls.open_database) + + def __init__(self, replica_uid=None, document_factory=None): + super(ObjectStoreDatabase, self).__init__( + replica_uid, + document_factory=document_factory) + # sync data in memory with data in object store + if not self._get_doc(self.U1DB_DATA_DOC_ID): + self._init_u1db_data() + self._fetch_u1db_data() + + #------------------------------------------------------------------------- + # methods from Database + #------------------------------------------------------------------------- + + def _set_replica_uid(self, replica_uid): + super(ObjectStoreDatabase, self)._set_replica_uid(replica_uid) + self._store_u1db_data() + + def _put_doc(self, doc): + raise NotImplementedError(self._put_doc) + + def _get_doc(self, doc): + raise NotImplementedError(self._get_doc) + + def get_all_docs(self, include_deleted=False): + raise NotImplementedError(self.get_all_docs) + + def delete_doc(self, doc): + """Mark a document as deleted.""" + old_doc = self._get_doc(doc.doc_id, check_for_conflicts=True) + if old_doc is None: + raise errors.DocumentDoesNotExist + if old_doc.rev != doc.rev: + raise errors.RevisionConflict() + if old_doc.is_tombstone(): + raise errors.DocumentAlreadyDeleted + if old_doc.has_conflicts: + raise errors.ConflictedDoc() + new_rev = self._allocate_doc_rev(doc.rev) + doc.rev = new_rev + doc.make_tombstone() + self._put_and_update_indexes(old_doc, doc) + return new_rev + + # index-related methods + + def create_index(self, index_name, *index_expressions): + """ + Create an named index, which can then be queried for future lookups. + """ + raise NotImplementedError(self.create_index) + + def delete_index(self, index_name): + """Remove a named index.""" + super(ObjectStoreDatabase, self).delete_index(index_name) + self._store_u1db_data() + + def _replace_conflicts(self, doc, conflicts): + super(ObjectStoreDatabase, self)._replace_conflicts(doc, conflicts) + self._store_u1db_data() + + def _do_set_replica_gen_and_trans_id(self, other_replica_uid, + other_generation, + other_transaction_id): + super(ObjectStoreDatabase, self)._do_set_replica_gen_and_trans_id( + other_replica_uid, + other_generation, + other_transaction_id) + self._store_u1db_data() + + #------------------------------------------------------------------------- + # implemented methods from CommonBackend + #------------------------------------------------------------------------- + + def _put_and_update_indexes(self, old_doc, doc): + for index in self._indexes.itervalues(): + if old_doc is not None and not old_doc.is_tombstone(): + index.remove_json(old_doc.doc_id, old_doc.get_json()) + if not doc.is_tombstone(): + index.add_json(doc.doc_id, doc.get_json()) + trans_id = self._allocate_transaction_id() + self._put_doc(doc) + self._transaction_log.append((doc.doc_id, trans_id)) + self._store_u1db_data() + + #------------------------------------------------------------------------- + # methods specific for object stores + #------------------------------------------------------------------------- + + U1DB_DATA_DOC_ID = 'u1db_data' + + def _fetch_u1db_data(self): + """ + Fetch u1db configuration data from backend storage. + """ + NotImplementedError(self._fetch_u1db_data) + + def _store_u1db_data(self): + """ + Save u1db configuration data on backend storage. + """ + NotImplementedError(self._store_u1db_data) + + def _init_u1db_data(self): + """ + Initialize u1db configuration data on backend storage. + """ + NotImplementedError(self._init_u1db_data) + + +class ObjectStoreSyncTarget(InMemorySyncTarget): + pass diff --git a/soledad/backends/sqlcipher.py b/soledad/backends/sqlcipher.py new file mode 100644 index 00000000..5d2569bf --- /dev/null +++ b/soledad/backends/sqlcipher.py @@ -0,0 +1,163 @@ +"""A U1DB backend that uses SQLCipher as its persistence layer.""" + +import os +from pysqlcipher import dbapi2 +import time + +from leap import util +from u1db.backends import sqlite_backend +util.logger.debug( + "Monkey-patching u1db.backends.sqlite_backend with pysqlcipher.dbapi2..." +) +sqlite_backend.dbapi2 = dbapi2 + +from u1db import ( + errors, +) + +from leap.soledad.backends.leap_backend import LeapDocument + + +def open(path, password, create=True, document_factory=None, soledad=None): + """Open a database at the given location. + + Will raise u1db.errors.DatabaseDoesNotExist if create=False and the + database does not already exist. + + :param path: The filesystem path for the database to open. + :param create: True/False, should the database be created if it doesn't + already exist? + :param document_factory: A function that will be called with the same + parameters as Document.__init__. + :return: An instance of Database. + """ + return SQLCipherDatabase.open_database( + path, password, create=create, document_factory=document_factory, + soledad=soledad) + + +class DatabaseIsNotEncrypted(Exception): + """ + Exception raised when trying to open non-encrypted databases. + """ + pass + + +class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase): + """A U1DB implementation that uses SQLCipher as its persistence layer.""" + + _index_storage_value = 'expand referenced encrypted' + + @classmethod + def set_pragma_key(cls, db_handle, key): + db_handle.cursor().execute("PRAGMA key = '%s'" % key) + + def __init__(self, sqlite_file, password, document_factory=None, + soledad=None): + """Create a new sqlcipher file.""" + self._check_if_db_is_encrypted(sqlite_file) + self._db_handle = dbapi2.connect(sqlite_file) + SQLCipherDatabase.set_pragma_key(self._db_handle, password) + self._real_replica_uid = None + self._ensure_schema() + self._soledad = soledad + + def factory(doc_id=None, rev=None, json='{}', has_conflicts=False, + encrypted_json=None, syncable=True): + return LeapDocument(doc_id=doc_id, rev=rev, json=json, + has_conflicts=has_conflicts, + encrypted_json=encrypted_json, + syncable=syncable, soledad=self._soledad) + self.set_document_factory(factory) + + def _check_if_db_is_encrypted(self, sqlite_file): + if not os.path.exists(sqlite_file): + return + else: + try: + # try to open an encrypted database with the regular u1db + # backend should raise a DatabaseError exception. + sqlite_backend.SQLitePartialExpandDatabase(sqlite_file) + raise DatabaseIsNotEncrypted() + except dbapi2.DatabaseError: + pass + + @classmethod + def _open_database(cls, sqlite_file, password, document_factory=None, + soledad=None): + if not os.path.isfile(sqlite_file): + raise errors.DatabaseDoesNotExist() + tries = 2 + while True: + # Note: There seems to be a bug in sqlite 3.5.9 (with python2.6) + # where without re-opening the database on Windows, it + # doesn't see the transaction that was just committed + db_handle = dbapi2.connect(sqlite_file) + SQLCipherDatabase.set_pragma_key(db_handle, password) + c = db_handle.cursor() + v, err = cls._which_index_storage(c) + db_handle.close() + if v is not None: + break + # possibly another process is initializing it, wait for it to be + # done + if tries == 0: + raise err # go for the richest error? + tries -= 1 + time.sleep(cls.WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL) + return SQLCipherDatabase._sqlite_registry[v]( + sqlite_file, password, document_factory=document_factory, + soledad=soledad) + + @classmethod + def open_database(cls, sqlite_file, password, create, backend_cls=None, + document_factory=None, soledad=None): + """Open U1DB database using SQLCipher as backend.""" + try: + return cls._open_database(sqlite_file, password, + document_factory=document_factory, + soledad=soledad) + except errors.DatabaseDoesNotExist: + if not create: + raise + if backend_cls is None: + # default is SQLCipherPartialExpandDatabase + backend_cls = SQLCipherDatabase + return backend_cls(sqlite_file, password, + document_factory=document_factory, + soledad=soledad) + + def sync(self, url, creds=None, autocreate=True): + """ + Synchronize encrypted documents with remote replica exposed at url. + """ + from u1db.sync import Synchronizer + from leap.soledad.backends.leap_backend import LeapSyncTarget + return Synchronizer( + self, + LeapSyncTarget(url, + creds=creds, + soledad=self._soledad)).sync(autocreate=autocreate) + + def _extra_schema_init(self, c): + c.execute( + 'ALTER TABLE document ' + 'ADD COLUMN syncable BOOL NOT NULL DEFAULT TRUE') + + def _put_and_update_indexes(self, old_doc, doc): + super(SQLCipherDatabase, self)._put_and_update_indexes(old_doc, doc) + c = self._db_handle.cursor() + c.execute('UPDATE document SET syncable=? WHERE doc_id=?', + (doc.syncable, doc.doc_id)) + + def _get_doc(self, doc_id, check_for_conflicts=False): + doc = super(SQLCipherDatabase, self)._get_doc(doc_id, + check_for_conflicts) + if doc: + c = self._db_handle.cursor() + c.execute('SELECT syncable FROM document WHERE doc_id=?', + (doc.doc_id,)) + doc.syncable = bool(c.fetchone()[0]) + return doc + +sqlite_backend.SQLiteDatabase.register_implementation(SQLCipherDatabase) |