server.py 16.4 KB
Newer Older
Laurent Bachelier's avatar
Laurent Bachelier committed
1 2
# -*- coding: utf-8 -*-

3
# Copyright (C) 2011 Romain Bignon, Laurent Bachelier
Romain Bignon's avatar
Romain Bignon committed
4
#
Romain Bignon's avatar
Romain Bignon committed
5
# This file is part of assnet.
6
#
Romain Bignon's avatar
Romain Bignon committed
7
# assnet is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU Affero General Public License as published by
9 10
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
Romain Bignon's avatar
Romain Bignon committed
11
#
Romain Bignon's avatar
Romain Bignon committed
12
# assnet is distributed in the hope that it will be useful,
Romain Bignon's avatar
Romain Bignon committed
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU Affero General Public License for more details.
Romain Bignon's avatar
Romain Bignon committed
16
#
17
# You should have received a copy of the GNU Affero General Public License
Romain Bignon's avatar
Romain Bignon committed
18
# along with assnet. If not, see <http://www.gnu.org/licenses/>.
Romain Bignon's avatar
Romain Bignon committed
19 20


21
import posixpath
Laurent Bachelier's avatar
Laurent Bachelier committed
22
import re
23
import os
24
from paste import httpserver
25
from paste.auth.cookie import AuthCookieSigner
26
from paste.fileapp import FileApp as PasteFileApp
27
from webob import Request, Response
28 29
from webob.exc import HTTPError, HTTPFound, HTTPNotFound, HTTPForbidden, \
        HTTPMethodNotAllowed, HTTPInternalServerError
30
from paste.url import URL
31 32
from paste.auth.basic import AuthBasicAuthenticator
from paste.httpheaders import REMOTE_USER, AUTH_TYPE
33
from datetime import timedelta
34
import urlparse
35
import json
Laurent Bachelier's avatar
Laurent Bachelier committed
36

37 38
from .butt import Butt
from .storage import Storage
39
from .template import build_lookup, build_vars
40
from .users import Anonymous
41
from .routes import Router
42
from .filters import quote_url, quote_path
43
from .security import new_secret
44

Laurent Bachelier's avatar
Laurent Bachelier committed
45

46
__all__ = ['ViewAction', 'Action', 'Server', 'FileApp']
47

Laurent Bachelier's avatar
Laurent Bachelier committed
48

Romain Bignon's avatar
Romain Bignon committed
49
class Context(object):
50
    SANITIZE_REGEXP = re.compile(r'/[%s+r]+/|\\+|/+' % re.escape(r'/.'))
Laurent Bachelier's avatar
Laurent Bachelier committed
51

52 53
    def __init__(self, butt, environ, start_response):
        self.router = butt.router
Romain Bignon's avatar
Romain Bignon committed
54
        self.storage = Storage.lookup(environ.get("ASSNET_ROOT"))
55 56
        self._environ = environ
        self._start_response = start_response
Romain Bignon's avatar
Romain Bignon committed
57
        self.req = Request(environ)
58
        self.req.charset = 'utf8'
59
        # fix script_name for weird configurations
60 61 62 63 64
        if 'FORCE_SCRIPT_NAME' in environ:
            environ['SCRIPT_NAME'] = environ['FORCE_SCRIPT_NAME']
            environ['PATH_INFO'] = environ['PATH_INFO'][len(environ['SCRIPT_NAME']):]
            del environ['FORCE_SCRIPT_NAME']
        elif 'SCRIPT_URL' in environ:
65
            script_path = quote_path(environ['SCRIPT_URL'])
66 67
            if self.req.path_info:
                level = len(self.req.path_info.split('/')) - 1
Laurent Bachelier's avatar
Laurent Bachelier committed
68
                environ['SCRIPT_NAME'] = '/'.join(script_path.split('/')[:-level]) + '/'
69 70
            else:
                environ['SCRIPT_NAME'] = script_path
71
        self.res = Response()
Romain Bignon's avatar
Romain Bignon committed
72
        self.user = Anonymous()
Laurent Bachelier's avatar
Laurent Bachelier committed
73

74 75
        self._init_default_response()

76
        self._init_paths()
77
        self._init_template_vars()
78 79 80
        self.lookup = build_lookup(self.storage)

        self._init_default_config()
81
        self._init_session()
82 83

    def _init_paths(self):
84
        path = self.req.path_info
Laurent Bachelier's avatar
Laurent Bachelier committed
85
        # remove the trailing "/" server-side, and other nice stuff
86 87
        path = self.SANITIZE_REGEXP.sub('/', path)
        path = posixpath.normpath(path)
88 89
        if path in ('.', '/'):
            path = ''
90

91 92
        if self.storage:
            f = self.storage.get_file(path)
93
        else:
94
            f = None
95

96 97
        # File object, related to the real one on the file system
        self.file = f
98
        self.object_type = f.get_object_type() if f else None
99

100
        query_vars = self.req.GET.items()
Romain Bignon's avatar
Romain Bignon committed
101
        # Path of the file relative to the assnet root
102
        self.path = path
103
        # Root application URL (useful for links to Actions)
104
        self.root_url = URL(self.req.script_name).addpath('/')
Romain Bignon's avatar
Romain Bignon committed
105
        # URL after assnet web application base URL
106
        self.relurl = URL(self.req.path_info, query_vars)
107 108
        # Complete URL (without host)
        self.url = URL(urlparse.urljoin(self.root_url.url, self.relurl.url[1:]), query_vars)
109

110
    def _init_default_config(self):
111
        if not self.storage:
112
            return
113
        config = self.storage.get_config()
114
        self.cookie_secret = config.data["web"].get("cookie_secret")
115 116
        try:
            if self.cookie_secret is None:
117
                self.cookie_secret = new_secret()
118 119 120 121 122 123 124 125
                config.data["web"]["cookie_secret"] = self.cookie_secret
                config.save()
            # store the absolute root url (useful when in CLI)
            if not config.data["web"].get("root_url"):
                config.data["web"]["root_url"] = self.req.host_url + self.root_url.href
                config.save()
        except IOError:
            e = HTTPInternalServerError()
126
            self.template_vars.update({'scripts': [], 'stylesheets': []})
127 128
            e.body = self.render('error_writeconfig.html')
            raise e
129

130
    def _init_default_response(self):
131 132
        # defaults, may be changed later on
        self.res.status = 200
133
        self.res.headers['Content-Type'] = 'text/html; charset=UTF-8'
134
        self.res.cache_control.private = True
135

136
    def _init_template_vars(self):
137 138 139
        self.template_vars = build_vars(self.storage)
        # add and override variables specific to the web responses
        self.template_vars.update({
140
            'file': self.file,
141
            'path': self.path,
142
            'url': self.url,
143
            'root_url': self.root_url,
144
            'stylesheets': ['main.css', 'user.css'],
Laurent Bachelier's avatar
Laurent Bachelier committed
145
            'scripts': ['mootools-core-1.3.1.js', 'mootools-more-1.3.1.1.js', 'main.js'],
146
        })
147

148 149 150 151
    def render(self, template):
        return self.lookup.get_template(template).render(**self.template_vars)

    def respond(self):
152
        return self.res(self._environ, self._start_response)
153

154 155 156
    def iter_files(self):
        if self.object_type == "directory":
            for f in self.file.iter_children():
157
                if self.user.has_perms(f, f.PERM_IN):
158
                    yield f
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174

    def iter_files_recursively(self):
        if self.object_type != "directory":
            return
        for root, directories, files in os.walk(self.file.get_realpath()):
            path = root[len(self.storage.root):]
            f = self.storage.get_file(path)

            if not self.user.has_perms(f, f.PERM_LIST):
                continue

            yield f
            for filename in files:
                f = self.storage.get_file(os.path.join(path, filename))
                if self.user.has_perms(f, f.PERM_IN):
                    yield f
175

Laurent Bachelier's avatar
Laurent Bachelier committed
176
    def login(self, user, set_cookie=True):
177 178 179
        """
        Log in an user.
        user: User
180 181
        set_cookie: bool, to chose if we set the cookie to remember the user.

182 183 184 185
        Do note that if you replace the "res" attribute after,
        that the cookie will not be sent.
        """
        assert user.exists
186
        if set_cookie:
187
            signer = AuthCookieSigner(secret=self.cookie_secret, timeout=120*24*60)
188
            cookie = signer.sign(user.name.encode('utf-8'))
Romain Bignon's avatar
Romain Bignon committed
189
            self.res.set_cookie('assnet_auth', cookie,
190
                max_age=timedelta(days=120), httponly=True, path=quote_url(self.root_url))
191 192
            # ensure there is a session cookie
            self.update_session()
193 194
        self.user = user

Laurent Bachelier's avatar
Laurent Bachelier committed
195
    def logout(self, delete_cookie=True):
196 197
        """
        Log out the current user.
198 199
        delete_cookie: bool, to chose if we remove the cookie to forget the user.

200 201 202
        Do note that if you replace the "res" attribute after,
        that the cookie will not be removed.
        """
203
        if delete_cookie:
Romain Bignon's avatar
Romain Bignon committed
204
            self.res.delete_cookie('assnet_auth', path=quote_url(self.root_url))
205 206
        self.user = Anonymous()

207 208 209 210 211 212 213 214 215 216 217 218
    def update_session(self):
        """
        Update the session cookie from the session attribute, encoded in plain JSON.
        It is not designed to be authoritative.
        JSON is readable client-side and allows us to restrict the types
        injected by the cookie compared to pickle.

        session should be a dict.

        Do note that if you replace the "res" attribute after,
        that the session cookie will not be sent.
        """
Romain Bignon's avatar
Romain Bignon committed
219
        self.res.set_cookie('assnet_session', json.dumps(self.session),
220 221 222 223 224 225 226 227
            path=quote_url(self.root_url))

    def _init_session(self):
        """
        Get the session cookie. It must be a dict
        and will ignore invalid data.
        """
        try:
Romain Bignon's avatar
Romain Bignon committed
228
            session = json.loads(self.req.cookies.get('assnet_session', ''))
229 230 231 232 233 234 235
        except ValueError:
            session = dict()
        else:
            if not isinstance(session, dict):
                session = dict()
        self.session = session

236

237
class Action(object):
238 239 240 241 242 243 244
    """
    REST action.
    Overload any HTTP method you wish. By default, head()
    will call get().
    """
    METHODS = ['HEAD', 'GET', 'POST', 'PUT', 'DELETE']

245 246
    def __init__(self, ctx):
        self.ctx = ctx
247

248
    def answer(self):
249 250 251 252 253 254 255 256
        """
        Find out the HTTP request method is and call the right
        method.
        For user agents that do not support PUT or DELETE, the _method parameter
        can be provided in either the POST data or the query string.
        """
        req = self.ctx.req
        method = req.method
257
        if method == 'POST':
258
            param_method = req.POST.get('_method')
259 260
            if param_method:
                # it's silly to simulate these requests with a _method param
261
                if param_method in ('HEAD', 'GET', 'POST'):
262
                    method = None
263 264
                else:
                    method = param_method
265 266
        if method in self.METHODS:
            return getattr(self, method.lower(), self._unhandled_method)()
267
        return self._unhandled_method()
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289

    def head(self):
        return self.get()

    def get(self):
        return self._unhandled_method()

    def post(self):
        return self._unhandled_method()

    def put(self):
        return self._unhandled_method()

    def delete(self):
        return self._unhandled_method()

    def _unhandled_method(self):
        self.ctx.res = HTTPMethodNotAllowed()


class ViewAction(Action):
    pass
290

291

292 293 294 295 296 297 298 299 300 301 302 303
class WSGIMethodException(Exception):
    """
    Wrapper used to raise any WSGI application that is not an exception.
    """

    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        return self.app(environ, start_response)


304 305 306
class Dispatcher(object):
    def __init__(self, ctx):
        self.ctx = ctx
307
        self.basic_auther = AuthBasicAuthenticator(
Romain Bignon's avatar
Romain Bignon committed
308
            "assnet at %s" % quote_url(ctx.root_url),
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
            self.basic_authfunc)

    def basic_authfunc(self, environ, username, password):
        """
        Auth function for AuthBasicAuthenticator.
        Allows to login by username/password, or if the username is _key,
        allows to login by key.
        """
        if username == '_key':
            for user in self.ctx.storage.iter_users():
                if password == user.key:
                    # hack to pass the real username
                    environ['key_username'] = user.name
                    return True
        user = self.ctx.storage.get_user(username)
        return user and user.is_valid_password(password)
325

326
    def _authenticate(self):
327
        ctx = self.ctx
328
        authkey = ctx.req.params.get('authkey')
Romain Bignon's avatar
Romain Bignon committed
329
        cookie = ctx.req.cookies.get('assnet_auth')
330
        authby = ctx.req.params.get('authby')
331 332 333 334 335 336 337 338 339 340 341 342 343
        valid_user = None
        if authby == 'http':
            username = REMOTE_USER(ctx.req.environ)
            if not username:
                username = self.basic_auther(ctx.req.environ)
                if isinstance(username, str):
                    if username == '_key':
                        username = ctx.req.environ['key_username']
                    AUTH_TYPE.update(ctx.req.environ, 'basic')
                    REMOTE_USER.update(ctx.req.environ, username)
                    valid_user = ctx.storage.get_user(username)
                else:
                    raise WSGIMethodException(username.wsgi_application)
Laurent Bachelier's avatar
Laurent Bachelier committed
344
        if authkey:
345
            for user in ctx.storage.iter_users():
Laurent Bachelier's avatar
Laurent Bachelier committed
346 347
                if authkey == user.key:
                    # set the cookie for the following requests
348
                    valid_user = user
Laurent Bachelier's avatar
Laurent Bachelier committed
349
        elif cookie:
350
            signer = AuthCookieSigner(secret=ctx.cookie_secret)
Laurent Bachelier's avatar
Laurent Bachelier committed
351
            username = signer.auth(cookie)
352
            if username:
353
                username = username.decode('utf-8')
354
                valid_user = ctx.storage.get_user(username)
355
        if valid_user:
Romain Bignon's avatar
Romain Bignon committed
356
            has_cookies = cookie and 'assnet_session' in ctx.req.cookies
357
            ctx.login(valid_user, set_cookie=not has_cookies)
358

359
    def dispatch(self):
360 361 362
        ctx = self.ctx
        router = ctx.router

363
        if not ctx.storage:
364 365
            e = HTTPInternalServerError()
            ctx.template_vars.update({'scripts': [], 'stylesheets': []})
Romain Bignon's avatar
Romain Bignon committed
366 367
            if 'ASSNET_ROOT' in ctx.req.environ:
                ctx.template_vars['root'] = ctx.req.environ['ASSNET_ROOT']
368 369 370 371
                e.body = ctx.render('error_notworkingdir.html')
            else:
                e.body = ctx.render('error_norootpath.html')
            raise e
372

373
        self._authenticate()
374 375
        ctx.template_vars["user"] = ctx.user if ctx.user.exists else None

376 377
        # actions: not related to a file or directory
        # if we are in the root app URL
378
        if ctx.url.setvars().href == ctx.root_url.href:
379
            action = router.find_action(ctx.req.GET.get('action'))
380 381
            if action is not None:
                return action(ctx).answer()
382

383
        # check perms
384
        f = ctx.file
385 386
        if ctx.object_type == 'directory':
            if not ctx.user.has_perms(f, f.PERM_LIST):
387 388 389 390
                if ctx.user.has_perms(f, f.PERM_IN):
                    raise HTTPForbidden()
                else:
                    raise HTTPNotFound('File not found')
391
        elif not ctx.user.has_perms(f, f.PERM_READ):
392 393 394 395
            if ctx.user.has_perms(f, f.PERM_IN):
                raise HTTPForbidden()
            else:
                raise HTTPNotFound('File not found')
396

397
        # normalize paths
398 399 400
        if ctx.object_type:
            goodpath = ctx.path
            if ctx.object_type == 'directory' and not goodpath.endswith('/'):
401 402
                # there should be a trailing slash in the client URL
                # for directories but not for files
403
                goodpath += '/'
404
            if ctx.req.path_info != goodpath:
405 406 407 408 409 410
                root_url = ctx.root_url.url
                if goodpath.startswith('/'):
                    goodpath = goodpath[1:]
                if not root_url.endswith('/'):
                    root_url += '/'
                goodlocation = URL(urlparse.urljoin(root_url, goodpath), vars=ctx.url.vars)
411
                ctx.res = HTTPFound(location=quote_url(goodlocation))
412
                return
413

414 415
        # no object type means no real file exists
        if ctx.object_type is None:
416
            raise HTTPNotFound('File not found')
417

418
        # find the action to forward the request to
419
        view, action = router.find_view(f, ctx.req.GET.get('view'))
420 421 422 423 424
        if view and action:
            # find out current action/view and available views
            ctx.template_vars['view'] = view.name
            ctx.template_vars['available_views'] = \
                sorted(router.get_available_views(f), key=str)
425
            return action(ctx).answer()
426 427

        # action/view not found
428
        raise HTTPNotFound('No route found')
Laurent Bachelier's avatar
Laurent Bachelier committed
429

430

431 432 433 434 435 436 437 438 439 440
class FileApp(PasteFileApp):
    def guess_type(self):
        # add UTF-8 by default to text content-types
        guess = PasteFileApp.guess_type(self)
        content_type = guess[0]
        if content_type and "text/" in content_type and "charset=" not in content_type:
            content_type += "; charset=UTF-8"
        return (content_type, guess[1])


Romain Bignon's avatar
Romain Bignon committed
441
class Server(object):
442
    def __init__(self, root=None, default_env=None):
443 444
        """
        The optional root parameter is used to force a root directory.
Romain Bignon's avatar
Romain Bignon committed
445
        If not present, the ASSNET_ROOT of environ (provided by the HTTP server)
446 447
        will be used.
        """
448
        self.butt = Butt(router=Router())
449 450
        self.default_env = default_env or {}
        if root:
Romain Bignon's avatar
Romain Bignon committed
451
            self.default_env['ASSNET_ROOT'] = root
Romain Bignon's avatar
Romain Bignon committed
452 453

    def bind(self, hostname, port):
454
        httpserver.serve(self, host=hostname, port=str(port))
Romain Bignon's avatar
Romain Bignon committed
455

456
    def __call__(self, environ, start_response):
457 458 459
        """
        WSGI interface
        """
460 461
        for key, value in self.default_env.iteritems():
            environ.setdefault(key, value)
462
        try:
463
            ctx = Context(self.butt, environ, start_response)
464 465
            Dispatcher(ctx).dispatch()
            return ctx.respond()
466
        except (HTTPError, WSGIMethodException), e:
467
            return e(environ, start_response)