summaryrefslogtreecommitdiff
path: root/server/src/leap/soledad/server/lock_resource.py
blob: 0a602e26b228f5f0921070623082fe8f6c95c22d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# -*- 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)
            # respond success: should use 204 but u1db does not support it.
            self._responder.send_response_json(200)

    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