flaskdav.py 16 KB
Newer Older
iv's avatar
iv committed
1
from itsdangerous import Signer, base64_encode, base64_decode
2
from flask import Flask, request, render_template, make_response, g, Response
iv's avatar
iv committed
3
from flask.views import MethodView
iv's avatar
iv committed
4

iv's avatar
iv committed
5
import urlparse
iv's avatar
iv committed
6 7 8
import shutil
import utils
import os
iv's avatar
iv committed
9
import mimetypes
iv's avatar
iv committed
10 11 12 13

app = Flask(__name__.split('.')[0])
app.config.from_object(__name__)

14 15
BUFFER_SIZE = 128000

16 17 18
ALLOWED_METHODS = ['GET', 'PUT', 'PROPFIND', 'PROPPATCH', 'MKCOL', 'DELETE',
                   'COPY', 'MOVE', 'OPTIONS']

19 20 21 22 23 24
def generate_key():
    """
       set application's secret key used for HMAC signature
    """
    app.secret_key = os.urandom(24)

iv's avatar
iv committed
25
def debug(content):
26 27 28 29 30
    """
       print debug info if debug mode
    """
    if app.debug:
        print(content)
iv's avatar
iv committed
31 32

URI_BEGINNING_PATH = {
33
    'redirection': '/redirect/',
iv's avatar
iv committed
34 35 36
    'authorization': '/login/',
    'system': '/system/',
    'webdav': '/webdav/',
iv's avatar
iv committed
37
    'links': '/'
iv's avatar
iv committed
38 39
}

40 41 42 43 44
def generate_cookie_info(origin=None):
    """
       cookie content is based on Origin header and User-Agent
       (later HMAC'ed)
    """
45

46 47
    if not origin:
        origin = request.headers.get('Origin')
48
    useragent = request.headers.get('User-Agent')
49
    return '%s %s' % (str(origin), str(useragent))
50 51

def verify_cookie(cookey):
52 53 54 55
    """
       verify that the signature contained in the cookie corresponds to the
       informations sent by the app (see generate_cookie_info)
    """
56 57 58

    is_correct = False

59 60
    debug("verify_cookie for origin: " + base64_decode(cookey))
    cookie_value = request.cookies.get(cookey)
61
    if cookie_value:
62
        debug("cookie exists for this origin")
63
        s = Signer(app.secret_key)
64 65
        expected_cookie_content = \
            generate_cookie_info(base64_decode(cookey))
66 67 68 69 70
        expected_cookie_content = s.get_signature(expected_cookie_content)
        debug("verify_cookie: " + cookie_value + ", " + expected_cookie_content)

        if expected_cookie_content == cookie_value:
            debug('correct cookie')
71
            is_correct = True
72 73
        else:
            debug('incorrect cookie')
74 75

    return is_correct
iv's avatar
iv committed
76

77 78 79 80 81
def is_authorized():
    """
       is the app get authorization to access the WebDAV (check cookies)
    """

82
    debug('is authorized, looking into cookies:\n' + str(request.cookies))
83 84 85 86
    origin = request.headers.get('Origin')
    if origin is None: # request from same origin
        return True
    return verify_cookie(base64_encode(origin))
iv's avatar
iv committed
87 88


89 90 91
@app.before_request
def before_request():
    """
iv's avatar
iv committed
92 93 94
       * put in g the prepared response with status and headers
       that can be changed by some methods later
       * allow cross origin for webdav uri that are authorized
95
       and filter unauthorized requests!
iv's avatar
iv committed
96
       * prepare response to OPTIONS request on webdav
97 98 99 100 101 102 103
    """
    if request.path.startswith(URI_BEGINNING_PATH['webdav']):
        response = None

        headers = {}
        headers['Access-Control-Max-Age'] = '3600'
        headers['Access-Control-Allow-Credentials'] = 'true'
iv's avatar
iv committed
104
        headers['Access-Control-Allow-Headers'] = \
105 106 107
            'Origin, Accept, Accept-Encoding, Content-Length, ' + \
            'Content-Type, Authorization, Depth, If-Modified-Since, '+ \
            'If-None-Match'
iv's avatar
iv committed
108 109
        headers['Access-Control-Expose-Headers'] = \
            'Content-Type, Last-Modified, WWW-Authenticate'
110 111
        origin = request.headers.get('Origin')
        headers['Access-Control-Allow-Origin'] = origin
112 113

        specific_header = request.headers.get('Access-Control-Request-Headers')
iv's avatar
iv committed
114

115
        if is_authorized():
iv's avatar
iv committed
116
            status_code = 200
117 118 119 120 121 122

        elif request.method == 'OPTIONS' and specific_header:
            # tells the world we do CORS when authorized
            debug('OPTIONS request special header: ' + specific_header)
            headers['Access-Control-Request-Headers'] = specific_header
            headers['Access-Control-Allow-Methods'] = ', '.join(ALLOWED_METHODS)
iv's avatar
iv committed
123
            response = make_response('', 200, headers)
124 125
            return response

126
        else:
127
            s = Signer(app.secret_key)
iv's avatar
iv committed
128 129
            headers['WWW-Authenticate'] = 'Nayookie login_url=' + \
                urlparse.urljoin(request.url_root,
130 131
                URI_BEGINNING_PATH['authorization']) + '?sig=' + \
                s.get_signature(origin) + '{&back_url,origin}'
iv's avatar
iv committed
132
            response = make_response('', 401, headers)
133 134 135
            # do not handle the request if not authorized
            return response

iv's avatar
iv committed
136
        g.status = status_code
iv's avatar
iv committed
137
        debug('headers: ' + str(headers))
iv's avatar
iv committed
138
        g.headers = headers
139

iv's avatar
iv committed
140
class WebDAV(MethodView):
141
    """ WebDAV server that handles request when destinated to it """
142
    methods = ALLOWED_METHODS
iv's avatar
iv committed
143 144 145 146 147

    def __init__(self):
        self.baseuri = URI_BEGINNING_PATH['webdav']

    def get_body(self):
148 149 150 151
        """
           get the request's body
        """

iv's avatar
iv committed
152
        request_data = request.data
153 154 155 156 157 158 159

        try:
            length = int(request.headers.get('Content-length'))
        except ValueError:
            length = 0

        if not request_data and length:
iv's avatar
iv committed
160
            try:
iv's avatar
iv committed
161 162
                request_data = request.form.items()[0][0]
            except IndexError:
iv's avatar
iv committed
163 164 165 166
                request_data = None
        return request_data

    def get(self, pathname):
167 168 169 170
        """
           GET:
           return headers + body (resource content or list of resources)
        """
iv's avatar
iv committed
171 172
        status = g.status
        headers = g.headers
iv's avatar
iv committed
173
        debug('pathname: ' + pathname)
iv's avatar
iv committed
174 175

        localpath = app.fs_handler.uri2local(URI_BEGINNING_PATH['webdav'] + pathname)
iv's avatar
iv committed
176 177
        data = ''

178
        if os.path.isdir(localpath):
iv's avatar
iv committed
179
            data = render_template('get_collection.html', link_list=app.fs_handler.get_children(URI_BEGINNING_PATH['webdav'] + pathname))
180 181
        elif os.path.isfile(localpath):
            try:
182
                #print(localpath + ': ' + mimetypes.guess_type(localpath))
iv's avatar
iv committed
183 184 185 186
                headers["Content-type"] = mimetypes.guess_type(localpath)[0]
                data_resource = app.fs_handler.get_data(URI_BEGINNING_PATH['webdav'] + pathname)
                if len(data_resource) > BUFFER_SIZE:
                    def generate():
187
                        data = data_resource.read(BUFFER_SIZE)
iv's avatar
iv committed
188 189 190 191 192 193 194 195
                        while data:
                            debug('get a chunk: ' + data)
                            yield data
                            data = data_resource.read(BUFFER_SIZE)
                    return Response(response=generate(), status=status,
                                    headers=headers)
                return Response(response=data_resource.read(BUFFER_SIZE),
                                status=status, headers=headers)
196 197
            except Exception, e:
                debug(e)
iv's avatar
iv committed
198
                status = 500
199
        else:
iv's avatar
iv committed
200
            status = 404
iv's avatar
iv committed
201

iv's avatar
iv committed
202
        return make_response(data, status, headers)
iv's avatar
iv committed
203 204 205 206 207

    def put(self, pathname):
        """
            PUT:
            on collection: 405 Method Not Allowed,
iv's avatar
iv committed
208
            on ressource: create if not exists, else change content
iv's avatar
iv committed
209
        """
210

iv's avatar
iv committed
211 212
        status = g.status
        headers = g.headers
213

iv's avatar
iv committed
214
        localpath = app.fs_handler.uri2local(URI_BEGINNING_PATH['webdav'] + pathname)
215
        # TODO: get large request chunk by chunk...
216 217
        request_body = self.get_body()
        if request_body is None:
iv's avatar
iv committed
218
            status = 500
219
        elif os.path.isdir(localpath):
iv's avatar
iv committed
220
            status = 405
221
        else:
iv's avatar
iv committed
222
            status = app.fs_handler.put(URI_BEGINNING_PATH['webdav'] + pathname, request_body)
iv's avatar
iv committed
223

iv's avatar
iv committed
224
        return make_response('', status, headers)
iv's avatar
iv committed
225 226

    def propfind(self, pathname):
iv's avatar
iv committed
227 228 229 230 231
        """
           PROPFIND:
           return informations about the properties of a resource/collection
           into a XML body response
        """
iv's avatar
iv committed
232 233
        status = g.status
        headers = g.headers
234

iv's avatar
iv committed
235
        pf = utils.PropfindProcessor(
iv's avatar
iv committed
236
            URI_BEGINNING_PATH['webdav'] + pathname,
iv's avatar
iv committed
237 238 239 240
            app.fs_handler,
            request.headers.get('Depth', 'infinity'),
            self.get_body())
        try:
iv's avatar
iv committed
241
            response = make_response(pf.create_response() + '\n', status, headers)
iv's avatar
iv committed
242 243 244
        except IOError, e:
            debug(e)
            response = make_response('Not found', 404, headers)
iv's avatar
iv committed
245

iv's avatar
iv committed
246 247 248
        return response

    def proppatch(self, pathname):
249 250 251 252
        """
           PROPPATCH:
           allow changes of the properties
        """
253

iv's avatar
iv committed
254
        headers = g.headers
255 256

        # currently unsupported
iv's avatar
iv committed
257 258 259
        status = 501

        return make_response('', status, headers)
iv's avatar
iv committed
260 261 262 263

    def mkcol(self, pathname):
        """
            MKCOL:
iv's avatar
iv committed
264 265
            creates a collection (that corresponds to a directory on the file
            system)
iv's avatar
iv committed
266 267
        """

iv's avatar
iv committed
268
        headers = g.headers
269

iv's avatar
iv committed
270 271
        status = app.fs_handler.mkcol(URI_BEGINNING_PATH['webdav'] + pathname)
        return make_response('', status, headers)
iv's avatar
iv committed
272 273 274 275 276 277 278

    def delete(self, pathname):
        """
           DELETE:
           delete a resource or collection
        """

iv's avatar
iv committed
279 280
        status = g.status
        headers = g.headers
281

iv's avatar
iv committed
282
        localpath = app.fs_handler.uri2local(URI_BEGINNING_PATH['webdav'] + pathname)
283
        if not os.path.exists(localpath):
iv's avatar
iv committed
284
            status = 404
285 286 287
        if os.path.isdir(localpath):
            try:
                shutil.rmtree(localpath)
iv's avatar
iv committed
288
                status = 204
289
            except OSError:
iv's avatar
iv committed
290
                status = 403
291 292 293
        elif os.path.isfile(localpath):
            try:
                os.remove(localpath)
iv's avatar
iv committed
294
                status = 204
295
            except OSError:
iv's avatar
iv committed
296 297
                status = 403
        return make_response('', status, headers)
iv's avatar
iv committed
298 299 300 301 302 303 304

    def copy(self, pathname):
        """
           COPY:
           copy a resource or collection
        """

iv's avatar
iv committed
305 306
        status = g.status
        headers = g.headers
307

iv's avatar
iv committed
308
        localpath = app.fs_handler.uri2local(URI_BEGINNING_PATH['webdav'] + pathname)
309
        host = request.headers['Host']
310 311 312
        destination = request.headers['Destination'].split(
            host + URI_BEGINNING_PATH['webdav'],
            1)[-1]
313
        destination_path = app.fs_handler.uri2local(destination)
iv's avatar
iv committed
314
        debug('COPY: %s -> %s' % (localpath, destination_path))
315 316

        if not os.path.exists(localpath):
iv's avatar
iv committed
317
            status = 404
318
        elif not destination_path:
iv's avatar
iv committed
319
            status = 400
320 321
        elif 'Overwrite' in request.headers and \
        request.headers['Overwrite'] == 'F' \
iv's avatar
iv committed
322
        and os.path.exists(destination_path):
iv's avatar
iv committed
323
            status = 412
324
        else:
iv's avatar
iv committed
325
            status = 201
326
            if os.path.exists(destination_path):
iv's avatar
iv committed
327
                status = self.delete(destination)
328 329 330 331

            if os.path.isfile(localpath):
                try:
                    shutil.copy2(localpath, destination_path)
iv's avatar
iv committed
332
                except Exception:
iv's avatar
iv committed
333
                    debug('problem with copy2')
iv's avatar
iv committed
334
            else:
335 336
                try:
                    shutil.copytree(localpath, destination_path)
iv's avatar
iv committed
337
                except Exception:
iv's avatar
iv committed
338
                    debug('problem with copytree')
iv's avatar
iv committed
339
        return make_response('', status, headers)
iv's avatar
iv committed
340 341 342 343 344 345 346

    def move(self, pathname):
        """
           MOVE:
           move a resource or collection
        """

iv's avatar
iv committed
347
        headers = g.headers
348

iv's avatar
iv committed
349 350
        copy_response = self.copy(URI_BEGINNING_PATH['webdav'] + pathname)
        status = copy_response.status
351
        if copy_response.status == '201' or copy_response.status == '204':
iv's avatar
iv committed
352
            delete_response = self.delete(URI_BEGINNING_PATH['webdav'] + pathname)
353
            if delete_response.status != '204':
iv's avatar
iv committed
354
                status = '424'
iv's avatar
iv committed
355 356
        return response

357 358 359
    def options(self, pathname):
        """
           OPTIONS:
360 361
           used to process pre-flight request but response it supposed to be
           sent in the before_request in that case...
362 363
        """

iv's avatar
iv committed
364
        return make_response('', g.status, g.headers)
365

iv's avatar
iv committed
366
webdav_view = WebDAV.as_view('dav')
367 368 369 370 371 372 373 374 375 376
app.add_url_rule(
    '/webdav/',
    defaults={'pathname': ''},
    view_func=webdav_view
)

app.add_url_rule(
    URI_BEGINNING_PATH['webdav'] + '<path:pathname>',
    view_func=webdav_view
)
iv's avatar
iv committed
377 378


379 380 381 382 383 384 385 386 387 388 389 390 391
@app.route(URI_BEGINNING_PATH['redirection'], methods=['GET', 'POST'])
def redirect():
    """
        redirect the end user to the page or site given
        as query, for example: ?back_url=https://somewhere/to/go
    """
    back = request.args.get('back_url')
    if back:
        return make_response('', 301, {'Location': back})
        response.status = '301' # moved permanently
    else:
        return "Nowhere to redirect to."

392
@app.route(URI_BEGINNING_PATH['authorization'], methods=['GET', 'POST'])
iv's avatar
iv committed
393
def authorize():
394 395 396 397 398
    """ authorization page
        GET: returns page where the user can authorize an app to access the
             filesystem via the webdav server
        POST: set a cookie
    """
399 400

    origin = request.args.get('origin')
iv's avatar
iv committed
401

402
    if request.method == 'POST':
403
        response = make_response()
404
        debug(request.form.items())
405
        if request.form.get('continue') != 'true':
406 407 408
            debug('old key was: ' + app.secret_key)
            generate_key()
            debug('new key is: ' + app.secret_key)
409
        s = Signer(app.secret_key)
410 411 412 413
        if s.get_signature(origin) == request.args.get('sig'):
            key = base64_encode(str(origin))
            back = request.args.get('back_url')

414 415 416 417
            info = generate_cookie_info(origin=origin)
            debug('Correct origin, setting cookie with info: ' + info)
            response.set_cookie(key, value=s.get_signature(info), max_age=None,
                expires=None, path='/', domain=None, secure=True, httponly=True)
418 419
        else:
            return 'Something went wrong...'
420

421
        response.status = '301' # moved permanently
iv's avatar
iv committed
422
        response.headers['Location'] = '/' if not back else back
423

424
    else:
425
        debug(request.args)
426
        response = make_response(render_template('authorization_page.html',
427 428 429 430
                                 cookie_list=[ base64_decode(cookey)
                                               for cookey in
                                               request.cookies.keys()
                                               if verify_cookie(cookey) ],
431 432
                                 origin=request.args.get('origin'),
                                 back_url=request.args.get('back_url')))
iv's avatar
iv committed
433 434 435 436
    return response

@app.route(URI_BEGINNING_PATH['system'])
def system():
437 438 439 440
    """
       TODO: page with system informations
    """
    return "system info"
iv's avatar
iv committed
441 442

@app.route('/')
443
def link_page():
444 445 446 447
    """
       TODO: nice set of links to useful local pages
       + HOWTO use the server
    """
448 449 450
    link_correspondance = ([ (what, where)
                             for what, where in URI_BEGINNING_PATH.iteritems()])
    return render_template('link_page.html', link_correspondance=link_correspondance)
451

iv's avatar
iv committed
452 453

if __name__ == '__main__':
454
    import argparse
455 456
    parser = argparse.ArgumentParser(description=\
                                     'Run a local webdav/HTTP server.')
457
    parser.add_argument('-d', '--debug', action='store_true',
458 459
                        help='Run flask app in debug mode (not recommended ' +
                             'for use in production).')
460
    parser.add_argument('-p', '--path', action='store',
461 462 463
                        help='Path to use as WebDAV server base')
    https = parser.add_argument_group('HTTPS',
                                      'Arguments required for HTTPS support.')
464 465 466 467 468 469 470 471
    https.add_argument('--key', type=str, action='store', default=None,
                       help='SSL/TLS private key. Required for HTTPS support.')
    https.add_argument('--cert', type=str, action='store', default=None,
                       help='SSL/TLS certificate. Required for HTTPS support.')

    args = parser.parse_args()
    app.debug = args.debug

472
    app.fs_path = '/tmp/' if not args.path else args.path
473 474
    app.fs_handler = utils.FilesystemHandler(app.fs_path,
                                             URI_BEGINNING_PATH['webdav'])
475

476
    context = None
477 478
    if args.key and args.cert and os.path.isfile(args.key) \
    and os.path.isfile(args.cert):
479 480 481 482 483 484
        from OpenSSL import SSL
        context = SSL.Context(SSL.TLSv1_2_METHOD)
        # TODO set strong ciphers with context.set_cipher_list()
        context.use_privatekey_file('ssl.key')
        context.use_certificate_file('ssl.cert')

485 486 487 488
    if app.debug:
        app.secret_key = 'maybe you should change me...'
    else:
        generate_key()
489
    app.run(host="localhost", ssl_context=context)