diff options
Diffstat (limited to 'server')
| -rw-r--r-- | server/changes/VERSION_COMPAT | 0 | ||||
| -rw-r--r-- | server/src/leap/soledad/server/__init__.py | 221 | ||||
| -rw-r--r-- | server/src/leap/soledad/server/_version.py | 46 | ||||
| -rw-r--r-- | server/src/leap/soledad/server/auth.py | 63 | ||||
| -rw-r--r-- | server/src/leap/soledad/server/gzip_middleware.py | 67 | ||||
| -rw-r--r-- | server/src/leap/soledad/server/lock_resource.py | 232 | ||||
| -rw-r--r-- | server/versioneer.py | 10 | 
7 files changed, 387 insertions, 252 deletions
| diff --git a/server/changes/VERSION_COMPAT b/server/changes/VERSION_COMPAT new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/server/changes/VERSION_COMPAT 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 diff --git a/server/versioneer.py b/server/versioneer.py index b43ab062..18dfd923 100644 --- a/server/versioneer.py +++ b/server/versioneer.py @@ -115,7 +115,7 @@ import sys  def run_command(args, cwd=None, verbose=False):      try: -        # remember shell=False, so use git.cmd on windows, not just git +        # remember shell=False, so use git.exe on windows, not just git          p = subprocess.Popen(args, stdout=subprocess.PIPE, cwd=cwd)      except EnvironmentError:          e = sys.exc_info()[1] @@ -230,7 +230,7 @@ def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):      GIT = "git"      if sys.platform == "win32": -        GIT = "git.cmd" +        GIT = "git.exe"      stdout = run_command([GIT, "describe", "--tags", "--dirty", "--always"],                           cwd=root)      if stdout is None: @@ -305,7 +305,7 @@ import sys  def run_command(args, cwd=None, verbose=False):      try: -        # remember shell=False, so use git.cmd on windows, not just git +        # remember shell=False, so use git.exe on windows, not just git          p = subprocess.Popen(args, stdout=subprocess.PIPE, cwd=cwd)      except EnvironmentError:          e = sys.exc_info()[1] @@ -430,7 +430,7 @@ def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):      GIT = "git"      if sys.platform == "win32": -        GIT = "git.cmd" +        GIT = "git.exe"      stdout = run_command([GIT, "describe", "--tags", "--dirty", "--always"],                           cwd=root)      if stdout is None: @@ -486,7 +486,7 @@ import sys  def do_vcs_install(versionfile_source, ipy):      GIT = "git"      if sys.platform == "win32": -        GIT = "git.cmd" +        GIT = "git.exe"      run_command([GIT, "add", "versioneer.py"])      run_command([GIT, "add", versionfile_source])      run_command([GIT, "add", ipy]) | 
