summaryrefslogtreecommitdiff
path: root/server/src/leap
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/leap')
-rw-r--r--server/src/leap/soledad/server/__init__.py221
-rw-r--r--server/src/leap/soledad/server/_version.py46
-rw-r--r--server/src/leap/soledad/server/auth.py63
-rw-r--r--server/src/leap/soledad/server/gzip_middleware.py67
-rw-r--r--server/src/leap/soledad/server/lock_resource.py232
5 files changed, 382 insertions, 247 deletions
diff --git a/server/src/leap/soledad/server/__init__.py b/server/src/leap/soledad/server/__init__.py
index c80b4c68..c170f230 100644
--- a/server/src/leap/soledad/server/__init__.py
+++ b/server/src/leap/soledad/server/__init__.py
@@ -87,9 +87,6 @@ and lock documents on the shared database is handled by
"""
import configparser
-import time
-import hashlib
-import os
from u1db.remote import http_app
@@ -98,10 +95,6 @@ from u1db.remote import http_app
from OpenSSL import tsafe
old_tsafe = tsafe
-from twisted.web.wsgi import WSGIResource
-from twisted.internet import reactor
-from twisted.internet.error import TimeoutError
-from twisted.python.lockfile import FilesystemLock
from twisted import version
if version.base() == "12.0.0":
# Put OpenSSL's tsafe back into place. This can probably be removed if we
@@ -110,22 +103,21 @@ if version.base() == "12.0.0":
sys.modules['OpenSSL.tsafe'] = old_tsafe
from leap.soledad.server.auth import SoledadTokenAuthMiddleware
-from leap.soledad.common import (
- SHARED_DB_NAME,
- SHARED_DB_LOCK_DOC_ID_PREFIX,
-)
+from leap.soledad.server.gzip_middleware import GzipMiddleware
+from leap.soledad.server.lock_resource import LockResource
+
+from leap.soledad.common import SHARED_DB_NAME
from leap.soledad.common.couch import CouchServerState
-from leap.soledad.common.errors import (
- InvalidTokenError,
- NotLockedError,
- AlreadyLockedError,
-)
#-----------------------------------------------------------------------------
# Soledad WSGI application
#-----------------------------------------------------------------------------
+MAX_REQUEST_SIZE = 200 # in Mb
+MAX_ENTRY_SIZE = 200 # in Mb
+
+
class SoledadApp(http_app.HTTPApp):
"""
Soledad WSGI application
@@ -136,6 +128,9 @@ class SoledadApp(http_app.HTTPApp):
The name of the shared database that holds user's encrypted secrets.
"""
+ max_request_size = MAX_REQUEST_SIZE * 1024 * 1024
+ max_entry_size = MAX_ENTRY_SIZE * 1024 * 1024
+
def __call__(self, environ, start_response):
"""
Handle a WSGI call to the Soledad application.
@@ -149,195 +144,12 @@ class SoledadApp(http_app.HTTPApp):
@return: HTTP application results.
@rtype: list
"""
- # ensure the shared database exists
- self.state.ensure_database(self.SHARED_DB_NAME)
return http_app.HTTPApp.__call__(self, environ, start_response)
-#
-# LockResource: a lock based on a document in the shared database.
-#
-
-@http_app.url_to_resource.register
-class LockResource(object):
- """
- Handle requests for locking documents.
-
- This class uses Twisted's Filesystem lock to manage a lock in the shared
- database.
- """
-
- url_pattern = '/%s/lock/{uuid}' % SoledadApp.SHARED_DB_NAME
- """
- """
-
- TIMEOUT = 300 # XXX is 5 minutes reasonable?
- """
- The timeout after which the lock expires.
- """
-
- # used for lock doc storage
- TIMESTAMP_KEY = '_timestamp'
- LOCK_TOKEN_KEY = '_token'
-
- FILESYSTEM_LOCK_TRIES = 5
- FILESYSTEM_LOCK_SLEEP_SECONDS = 1
-
-
- def __init__(self, uuid, state, responder):
- """
- Initialize the lock resource. Parameters to this constructor are
- automatically passed by u1db.
-
- :param uuid: The user unique id.
- :type uuid: str
- :param state: The backend database state.
- :type state: u1db.remote.ServerState
- :param responder: The infrastructure to send responses to client.
- :type responder: u1db.remote.HTTPResponder
- """
- self._shared_db = state.open_database(SoledadApp.SHARED_DB_NAME)
- self._lock_doc_id = '%s%s' % (SHARED_DB_LOCK_DOC_ID_PREFIX, uuid)
- self._lock = FilesystemLock(
- hashlib.sha512(self._lock_doc_id).hexdigest())
- self._state = state
- self._responder = responder
-
- @http_app.http_method(content=str)
- def put(self, content=None):
- """
- Handle a PUT request to the lock document.
-
- A lock is a document in the shared db with doc_id equal to
- 'lock-<uuid>' and the timestamp of its creation as content. This
- method obtains a threaded-lock and creates a lock document if it does
- not exist or if it has expired.
-
- It returns '201 Created' and a pair containing a token to unlock and
- the lock timeout, or '403 AlreadyLockedError' and the remaining amount
- of seconds the lock will still be valid.
-
- :param content: The content of the PUT request. It is only here
- because PUT requests with empty content are considered
- invalid requests by u1db.
- :type content: str
- """
- # obtain filesystem lock
- if not self._try_obtain_filesystem_lock():
- self._responder.send_response_json(408) # error: request timeout
- return
-
- created_lock = False
- now = time.time()
- token = hashlib.sha256(os.urandom(10)).hexdigest() # for releasing
- lock_doc = self._shared_db.get_doc(self._lock_doc_id)
- remaining = self._remaining(lock_doc, now)
-
- # if there's no lock, create one
- if lock_doc is None:
- lock_doc = self._shared_db.create_doc(
- {
- self.TIMESTAMP_KEY: now,
- self.LOCK_TOKEN_KEY: token,
- },
- doc_id=self._lock_doc_id)
- created_lock = True
- else:
- if remaining == 0:
- # lock expired, create new one
- lock_doc.content = {
- self.TIMESTAMP_KEY: now,
- self.LOCK_TOKEN_KEY: token,
- }
- self._shared_db.put_doc(lock_doc)
- created_lock = True
-
- self._try_release_filesystem_lock()
-
- # send response to client
- if created_lock is True:
- self._responder.send_response_json(
- 201, timeout=self.TIMEOUT, token=token) # success: created
- else:
- wire_descr = AlreadyLockedError.wire_description
- self._responder.send_response_json(
- AlreadyLockedError.status, # error: forbidden
- error=AlreadyLockedError.wire_description, remaining=remaining)
-
- @http_app.http_method(token=str)
- def delete(self, token=None):
- """
- Delete the lock if the C{token} is valid.
-
- Delete the lock document in case C{token} is equal to the token stored
- in the lock document.
-
- :param token: The token returned when locking.
- :type token: str
-
- :raise NotLockedError: Raised in case the lock is not locked.
- :raise InvalidTokenError: Raised in case the token is invalid for
- unlocking.
- """
- lock_doc = self._shared_db.get_doc(self._lock_doc_id)
- if lock_doc is None or self._remaining(lock_doc, time.time()) == 0:
- self._responder.send_response_json(
- NotLockedError.status, # error: not found
- error=NotLockedError.wire_description)
- elif token != lock_doc.content[self.LOCK_TOKEN_KEY]:
- self._responder.send_response_json(
- InvalidTokenError.status, # error: unauthorized
- error=InvalidTokenError.wire_description)
- else:
- self._shared_db.delete_doc(lock_doc)
- self._responder.send_response_json(200) # success: should use 204
- # but u1db does not
- # support it.
-
- def _remaining(self, lock_doc, now):
- """
- Return the number of seconds the lock contained in C{lock_doc} is
- still valid, when compared to C{now}.
-
- :param lock_doc: The document containing the lock.
- :type lock_doc: u1db.Document
- :param now: The time to which to compare the lock timestamp.
- :type now: float
-
- :return: The amount of seconds the lock is still valid.
- :rtype: float
- """
- if lock_doc is not None:
- lock_timestamp = lock_doc.content[self.TIMESTAMP_KEY]
- remaining = lock_timestamp + self.TIMEOUT - now
- return remaining if remaining > 0 else 0.0
- return 0.0
-
- def _try_obtain_filesystem_lock(self):
- """
- Try to obtain the file system lock.
-
- @return: Whether the lock was succesfully obtained.
- @rtype: bool
- """
- tries = self.FILESYSTEM_LOCK_TRIES
- while tries > 0:
- try:
- return self._lock.lock()
- except Exception as e:
- tries -= 1
- time.sleep(self.FILESYSTEM_LOCK_SLEEP_SECONDS)
- return False
-
- def _try_release_filesystem_lock(self):
- """
- Release the filesystem lock.
- """
- try:
- self._lock.unlock()
- return True
- except Exception:
- return False
+http_app.url_to_resource.register(LockResource)
+http_app.SyncResource.max_request_size = MAX_REQUEST_SIZE * 1024 * 1024
+http_app.SyncResource.max_entry_size = MAX_ENTRY_SIZE * 1024 * 1024
#-----------------------------------------------------------------------------
@@ -379,8 +191,9 @@ def application(environ, start_response):
SoledadApp.SHARED_DB_NAME,
SoledadTokenAuthMiddleware.TOKENS_DB)
# WSGI application that may be used by `twistd -web`
- application = SoledadTokenAuthMiddleware(SoledadApp(state))
- resource = WSGIResource(reactor, reactor.getThreadPool(), application)
+ application = GzipMiddleware(
+ SoledadTokenAuthMiddleware(SoledadApp(state)))
+
return application(environ, start_response)
diff --git a/server/src/leap/soledad/server/_version.py b/server/src/leap/soledad/server/_version.py
index 85f0e54c..ec611c39 100644
--- a/server/src/leap/soledad/server/_version.py
+++ b/server/src/leap/soledad/server/_version.py
@@ -17,6 +17,7 @@ git_full = "$Format:%H$"
import subprocess
import sys
+
def run_command(args, cwd=None, verbose=False):
try:
# remember shell=False, so use git.cmd on windows, not just git
@@ -36,11 +37,10 @@ def run_command(args, cwd=None, verbose=False):
return None
return stdout
-
-import sys
import re
import os.path
+
def get_expanded_variables(versionfile_source):
# the code embedded in _version.py can just fetch the value of these
# variables. When used from setup.py, we don't want to import
@@ -48,7 +48,7 @@ def get_expanded_variables(versionfile_source):
# used from _version.py.
variables = {}
try:
- f = open(versionfile_source,"r")
+ f = open(versionfile_source, "r")
for line in f.readlines():
if line.strip().startswith("git_refnames ="):
mo = re.search(r'=\s*"(.*)"', line)
@@ -63,12 +63,13 @@ def get_expanded_variables(versionfile_source):
pass
return variables
+
def versions_from_expanded_variables(variables, tag_prefix, verbose=False):
refnames = variables["refnames"].strip()
if refnames.startswith("$Format"):
if verbose:
print("variables are unexpanded, not using")
- return {} # unexpanded, so not in an unpacked git-archive tarball
+ return {} # unexpanded, so not in an unpacked git-archive tarball
refs = set([r.strip() for r in refnames.strip("()").split(",")])
# starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
# just "foo-1.0". If we see a "tag: " prefix, prefer those.
@@ -93,13 +94,14 @@ def versions_from_expanded_variables(variables, tag_prefix, verbose=False):
r = ref[len(tag_prefix):]
if verbose:
print("picking %s" % r)
- return { "version": r,
- "full": variables["full"].strip() }
+ return {"version": r,
+ "full": variables["full"].strip()}
# no suitable tags, so we use the full revision id
if verbose:
print("no suitable tags, using full revision id")
- return { "version": variables["full"].strip(),
- "full": variables["full"].strip() }
+ return {"version": variables["full"].strip(),
+ "full": variables["full"].strip()}
+
def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):
# this runs 'git' from the root of the source tree. That either means
@@ -116,7 +118,7 @@ def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):
here = os.path.abspath(__file__)
except NameError:
# some py2exe/bbfreeze/non-CPython implementations don't do __file__
- return {} # not always correct
+ return {} # not always correct
# versionfile_source is the relative path from the top of the source tree
# (where the .git directory might live) to this file. Invert this to find
@@ -126,7 +128,16 @@ def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):
for i in range(len(versionfile_source.split("/"))):
root = os.path.dirname(root)
else:
- root = os.path.dirname(here)
+ root = os.path.dirname(
+ os.path.join('..', here))
+
+ ######################################################
+ # XXX patch for our specific configuration with
+ # the three projects leap.soledad.{common, client, server}
+ # inside the same repo.
+ ######################################################
+ root = os.path.dirname(os.path.join('..', root))
+
if not os.path.exists(os.path.join(root, ".git")):
if verbose:
print("no .git in %s" % root)
@@ -141,7 +152,8 @@ def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):
return {}
if not stdout.startswith(tag_prefix):
if verbose:
- print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix))
+ print("tag '%s' doesn't start with prefix '%s'" %
+ (stdout, tag_prefix))
return {}
tag = stdout[len(tag_prefix):]
stdout = run_command([GIT, "rev-parse", "HEAD"], cwd=root)
@@ -153,7 +165,8 @@ def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):
return {"version": tag, "full": full}
-def versions_from_parentdir(parentdir_prefix, versionfile_source, verbose=False):
+def versions_from_parentdir(parentdir_prefix, versionfile_source,
+ verbose=False):
if IN_LONG_VERSION_PY:
# We're running from _version.py. If it's from a source tree
# (execute-in-place), we can work upwards to find the root of the
@@ -163,7 +176,7 @@ def versions_from_parentdir(parentdir_prefix, versionfile_source, verbose=False)
here = os.path.abspath(__file__)
except NameError:
# py2exe/bbfreeze/non-CPython don't have __file__
- return {} # without __file__, we have no hope
+ return {} # without __file__, we have no hope
# versionfile_source is the relative path from the top of the source
# tree to _version.py. Invert this to find the root from __file__.
root = here
@@ -180,7 +193,8 @@ def versions_from_parentdir(parentdir_prefix, versionfile_source, verbose=False)
dirname = os.path.basename(root)
if not dirname.startswith(parentdir_prefix):
if verbose:
- print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" %
+ print("guessing rootdir is '%s', but '%s' doesn't start "
+ "with prefix '%s'" %
(root, dirname, parentdir_prefix))
return None
return {"version": dirname[len(parentdir_prefix):], "full": ""}
@@ -189,8 +203,9 @@ tag_prefix = ""
parentdir_prefix = "leap.soledad.server-"
versionfile_source = "src/leap/soledad/server/_version.py"
+
def get_versions(default={"version": "unknown", "full": ""}, verbose=False):
- variables = { "refnames": git_refnames, "full": git_full }
+ variables = {"refnames": git_refnames, "full": git_full}
ver = versions_from_expanded_variables(variables, tag_prefix, verbose)
if not ver:
ver = versions_from_vcs(tag_prefix, versionfile_source, verbose)
@@ -200,4 +215,3 @@ def get_versions(default={"version": "unknown", "full": ""}, verbose=False):
if not ver:
ver = default
return ver
-
diff --git a/server/src/leap/soledad/server/auth.py b/server/src/leap/soledad/server/auth.py
index 0ae49576..e9d2b032 100644
--- a/server/src/leap/soledad/server/auth.py
+++ b/server/src/leap/soledad/server/auth.py
@@ -25,7 +25,7 @@ import httplib
import simplejson as json
-from u1db import DBNAME_CONSTRAINTS
+from u1db import DBNAME_CONSTRAINTS, errors as u1db_errors
from abc import ABCMeta, abstractmethod
from routes.mapper import Mapper
from couchdb.client import Server
@@ -37,16 +37,7 @@ from leap.soledad.common import (
SHARED_DB_LOCK_DOC_ID_PREFIX,
USER_DB_PREFIX,
)
-
-
-#-----------------------------------------------------------------------------
-# Authentication
-#-----------------------------------------------------------------------------
-
-class Unauthorized(Exception):
- """
- User authentication failed.
- """
+from leap.soledad.common.errors import InvalidAuthTokenError
class URLToAuthorization(object):
@@ -279,10 +270,16 @@ class SoledadAuthMiddleware(object):
return self._unauthorized_error("Wrong authentication scheme")
# verify if user is athenticated
- if not self._verify_authentication_data(uuid, auth_data):
- return self._unauthorized_error(
+ try:
+ if not self._verify_authentication_data(uuid, auth_data):
+ return self._unauthorized_error(
+ start_response,
+ self._get_auth_error_string())
+ except u1db_errors.Unauthorized as e:
+ return self._error(
start_response,
- self._get_auth_error_string())
+ 401,
+ e.wire_description)
# verify if user is authorized to perform action
if not self._verify_authorization(environ, uuid):
@@ -319,6 +316,9 @@ class SoledadAuthMiddleware(object):
@return: Whether the token is valid for authenticating the request.
@rtype: bool
+
+ @raise Unauthorized: Raised when C{auth_data} is not enough to
+ authenticate C{uuid}.
"""
return None
@@ -386,11 +386,20 @@ class SoledadTokenAuthMiddleware(SoledadAuthMiddleware):
@return: Whether the token is valid for authenticating the request.
@rtype: bool
+
+ @raise Unauthorized: Raised when C{auth_data} is not enough to
+ authenticate C{uuid}.
"""
token = auth_data # we expect a cleartext token at this point
- return self._verify_token_in_couchdb(uuid, token)
+ try:
+ return self._verify_token_in_couch(uuid, token)
+ except InvalidAuthTokenError:
+ raise
+ except Exception as e:
+ log.err(e)
+ return False
- def _verify_token_in_couchdb(self, uuid, token):
+ def _verify_token_in_couch(self, uuid, token):
"""
Query couchdb to decide if C{token} is valid for C{uuid}.
@@ -398,19 +407,19 @@ class SoledadTokenAuthMiddleware(SoledadAuthMiddleware):
@type uuid: str
@param token: The token.
@type token: str
+
+ @raise InvalidAuthTokenError: Raised when token received from user is
+ either missing in the tokens db or is
+ invalid.
"""
server = Server(url=self._app.state.couch_url)
- try:
- dbname = self.TOKENS_DB
- db = server[dbname]
- token = db.get(token)
- if token is None:
- return False
- return token[self.TOKENS_TYPE_KEY] == self.TOKENS_TYPE_DEF and \
- token[self.TOKENS_USER_ID_KEY] == uuid
- except Exception as e:
- log.err(e)
- return False
+ dbname = self.TOKENS_DB
+ db = server[dbname]
+ token = db.get(token)
+ if token is None or \
+ token[self.TOKENS_TYPE_KEY] != self.TOKENS_TYPE_DEF or \
+ token[self.TOKENS_USER_ID_KEY] != uuid:
+ raise InvalidAuthTokenError()
return True
def _get_auth_error_string(self):
diff --git a/server/src/leap/soledad/server/gzip_middleware.py b/server/src/leap/soledad/server/gzip_middleware.py
new file mode 100644
index 00000000..986c5738
--- /dev/null
+++ b/server/src/leap/soledad/server/gzip_middleware.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# gzip_middleware.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program 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 <http://www.gnu.org/licenses/>.
+"""
+Gzip middleware for WSGI apps.
+"""
+import StringIO
+from gzip import GzipFile
+
+
+class GzipMiddleware(object):
+ """
+ GzipMiddleware class for WSGI.
+ """
+ def __init__(self, app, compresslevel=9):
+ self.app = app
+ self.compresslevel = compresslevel
+
+ def __call__(self, environ, start_response):
+ if 'gzip' not in environ.get('HTTP_ACCEPT_ENCODING', ''):
+ return self.app(environ, start_response)
+
+ buffer = StringIO.StringIO()
+ output = GzipFile(
+ mode='wb',
+ compresslevel=self.compresslevel,
+ fileobj=buffer
+ )
+
+ start_response_args = []
+
+ def dummy_start_response(status, headers, exc_info=None):
+ start_response_args.append(status)
+ start_response_args.append(headers)
+ start_response_args.append(exc_info)
+ return output.write
+
+ app_iter = self.app(environ, dummy_start_response)
+ for line in app_iter:
+ output.write(line)
+ if hasattr(app_iter, 'close'):
+ app_iter.close()
+ output.close()
+ buffer.seek(0)
+ result = buffer.getvalue()
+ headers = []
+ for name, value in start_response_args[1]:
+ if name.lower() != 'content-length':
+ headers.append((name, value))
+ headers.append(('Content-Length', str(len(result))))
+ headers.append(('Content-Encoding', 'gzip'))
+ start_response(start_response_args[0], headers, start_response_args[2])
+ buffer.close()
+ return [result]
diff --git a/server/src/leap/soledad/server/lock_resource.py b/server/src/leap/soledad/server/lock_resource.py
new file mode 100644
index 00000000..a7870f77
--- /dev/null
+++ b/server/src/leap/soledad/server/lock_resource.py
@@ -0,0 +1,232 @@
+# -*- coding: utf-8 -*-
+# lock_resource.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program 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 <http://www.gnu.org/licenses/>.
+
+
+"""
+LockResource: a lock based on a document in the shared database.
+"""
+
+
+import hashlib
+import time
+import os
+import tempfile
+import errno
+
+
+from u1db.remote import http_app
+from twisted.python.lockfile import FilesystemLock
+
+
+from leap.soledad.common import (
+ SHARED_DB_NAME,
+ SHARED_DB_LOCK_DOC_ID_PREFIX,
+)
+from leap.soledad.common.errors import (
+ InvalidTokenError,
+ NotLockedError,
+ AlreadyLockedError,
+ LockTimedOutError,
+ CouldNotObtainLockError,
+)
+
+
+class LockResource(object):
+ """
+ Handle requests for locking documents.
+
+ This class uses Twisted's Filesystem lock to manage a lock in the shared
+ database.
+ """
+
+ url_pattern = '/%s/lock/{uuid}' % SHARED_DB_NAME
+ """
+ """
+
+ TIMEOUT = 300 # XXX is 5 minutes reasonable?
+ """
+ The timeout after which the lock expires.
+ """
+
+ # used for lock doc storage
+ TIMESTAMP_KEY = '_timestamp'
+ LOCK_TOKEN_KEY = '_token'
+
+ FILESYSTEM_LOCK_TRIES = 5
+ FILESYSTEM_LOCK_SLEEP_SECONDS = 1
+
+ def __init__(self, uuid, state, responder):
+ """
+ Initialize the lock resource. Parameters to this constructor are
+ automatically passed by u1db.
+
+ :param uuid: The user unique id.
+ :type uuid: str
+ :param state: The backend database state.
+ :type state: u1db.remote.ServerState
+ :param responder: The infrastructure to send responses to client.
+ :type responder: u1db.remote.HTTPResponder
+ """
+ self._shared_db = state.open_database(SHARED_DB_NAME)
+ self._lock_doc_id = '%s%s' % (SHARED_DB_LOCK_DOC_ID_PREFIX, uuid)
+ self._lock = FilesystemLock(
+ os.path.join(
+ tempfile.gettempdir(),
+ hashlib.sha512(self._lock_doc_id).hexdigest()))
+ self._state = state
+ self._responder = responder
+
+ @http_app.http_method(content=str)
+ def put(self, content=None):
+ """
+ Handle a PUT request to the lock document.
+
+ A lock is a document in the shared db with doc_id equal to
+ 'lock-<uuid>' and the timestamp of its creation as content. This
+ method obtains a threaded-lock and creates a lock document if it does
+ not exist or if it has expired.
+
+ It returns '201 Created' and a pair containing a token to unlock and
+ the lock timeout, or '403 AlreadyLockedError' and the remaining amount
+ of seconds the lock will still be valid.
+
+ :param content: The content of the PUT request. It is only here
+ because PUT requests with empty content are considered
+ invalid requests by u1db.
+ :type content: str
+ """
+ # obtain filesystem lock
+ if not self._try_obtain_filesystem_lock():
+ self._responder.send_response_json(
+ LockTimedOutError.status, # error: request timeout
+ error=LockTimedOutError.wire_description)
+ return
+
+ created_lock = False
+ now = time.time()
+ token = hashlib.sha256(os.urandom(10)).hexdigest() # for releasing
+ lock_doc = self._shared_db.get_doc(self._lock_doc_id)
+ remaining = self._remaining(lock_doc, now)
+
+ # if there's no lock, create one
+ if lock_doc is None:
+ lock_doc = self._shared_db.create_doc(
+ {
+ self.TIMESTAMP_KEY: now,
+ self.LOCK_TOKEN_KEY: token,
+ },
+ doc_id=self._lock_doc_id)
+ created_lock = True
+ else:
+ if remaining == 0:
+ # lock expired, create new one
+ lock_doc.content = {
+ self.TIMESTAMP_KEY: now,
+ self.LOCK_TOKEN_KEY: token,
+ }
+ self._shared_db.put_doc(lock_doc)
+ created_lock = True
+
+ self._try_release_filesystem_lock()
+
+ # send response to client
+ if created_lock is True:
+ self._responder.send_response_json(
+ 201, timeout=self.TIMEOUT, token=token) # success: created
+ else:
+ self._responder.send_response_json(
+ AlreadyLockedError.status, # error: forbidden
+ error=AlreadyLockedError.wire_description, remaining=remaining)
+
+ @http_app.http_method(token=str)
+ def delete(self, token=None):
+ """
+ Delete the lock if the C{token} is valid.
+
+ Delete the lock document in case C{token} is equal to the token stored
+ in the lock document.
+
+ :param token: The token returned when locking.
+ :type token: str
+
+ :raise NotLockedError: Raised in case the lock is not locked.
+ :raise InvalidTokenError: Raised in case the token is invalid for
+ unlocking.
+ """
+ lock_doc = self._shared_db.get_doc(self._lock_doc_id)
+ if lock_doc is None or self._remaining(lock_doc, time.time()) == 0:
+ self._responder.send_response_json(
+ NotLockedError.status, # error: not found
+ error=NotLockedError.wire_description)
+ elif token != lock_doc.content[self.LOCK_TOKEN_KEY]:
+ self._responder.send_response_json(
+ InvalidTokenError.status, # error: unauthorized
+ error=InvalidTokenError.wire_description)
+ else:
+ self._shared_db.delete_doc(lock_doc)
+ self._responder.send_response_json(200) # success: should use 204
+ # but u1db does not
+ # support it.
+
+ def _remaining(self, lock_doc, now):
+ """
+ Return the number of seconds the lock contained in C{lock_doc} is
+ still valid, when compared to C{now}.
+
+ :param lock_doc: The document containing the lock.
+ :type lock_doc: u1db.Document
+ :param now: The time to which to compare the lock timestamp.
+ :type now: float
+
+ :return: The amount of seconds the lock is still valid.
+ :rtype: float
+ """
+ if lock_doc is not None:
+ lock_timestamp = lock_doc.content[self.TIMESTAMP_KEY]
+ remaining = lock_timestamp + self.TIMEOUT - now
+ return remaining if remaining > 0 else 0.0
+ return 0.0
+
+ def _try_obtain_filesystem_lock(self):
+ """
+ Try to obtain the file system lock.
+
+ @return: Whether the lock was succesfully obtained.
+ @rtype: bool
+ """
+ tries = self.FILESYSTEM_LOCK_TRIES
+ while tries > 0:
+ try:
+ return self._lock.lock()
+ except OSError as e:
+ tries -= 1
+ if tries == 0:
+ raise CouldNotObtainLockError(e.message)
+ time.sleep(self.FILESYSTEM_LOCK_SLEEP_SECONDS)
+ return False
+
+ def _try_release_filesystem_lock(self):
+ """
+ Release the filesystem lock.
+ """
+ try:
+ self._lock.unlock()
+ return True
+ except OSError as e:
+ if e.errno == errno.ENOENT:
+ return True
+ return False