# -*- 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 .
"""
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-' 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