##// END OF EJS Templates
Merge pull request #4437 from minrk/etag...
Min RK -
r13326:e9b5d189 merge
parent child Browse files
Show More
@@ -1,357 +1,363 b''
1 """Base Tornado handlers for the notebook.
1 """Base Tornado handlers for the notebook.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2011 The IPython Development Team
9 # Copyright (C) 2011 The IPython Development Team
10 #
10 #
11 # Distributed under the terms of the BSD License. The full license is in
11 # Distributed under the terms of the BSD License. The full license is in
12 # the file COPYING, distributed as part of this software.
12 # the file COPYING, distributed as part of this software.
13 #-----------------------------------------------------------------------------
13 #-----------------------------------------------------------------------------
14
14
15 #-----------------------------------------------------------------------------
15 #-----------------------------------------------------------------------------
16 # Imports
16 # Imports
17 #-----------------------------------------------------------------------------
17 #-----------------------------------------------------------------------------
18
18
19
19
20 import functools
20 import functools
21 import json
21 import json
22 import logging
22 import logging
23 import os
23 import os
24 import stat
24 import stat
25 import sys
25 import sys
26 import traceback
26 import traceback
27
27
28 from tornado import web
28 from tornado import web
29
29
30 try:
30 try:
31 from tornado.log import app_log
31 from tornado.log import app_log
32 except ImportError:
32 except ImportError:
33 app_log = logging.getLogger()
33 app_log = logging.getLogger()
34
34
35 from IPython.config import Application
35 from IPython.config import Application
36 from IPython.utils.path import filefind
36 from IPython.utils.path import filefind
37
37
38 # UF_HIDDEN is a stat flag not defined in the stat module.
38 # UF_HIDDEN is a stat flag not defined in the stat module.
39 # It is used by BSD to indicate hidden files.
39 # It is used by BSD to indicate hidden files.
40 UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768)
40 UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768)
41
41
42 #-----------------------------------------------------------------------------
42 #-----------------------------------------------------------------------------
43 # Top-level handlers
43 # Top-level handlers
44 #-----------------------------------------------------------------------------
44 #-----------------------------------------------------------------------------
45
45
46 class RequestHandler(web.RequestHandler):
46 class RequestHandler(web.RequestHandler):
47 """RequestHandler with default variable setting."""
47 """RequestHandler with default variable setting."""
48
48
49 def render(*args, **kwargs):
49 def render(*args, **kwargs):
50 kwargs.setdefault('message', '')
50 kwargs.setdefault('message', '')
51 return web.RequestHandler.render(*args, **kwargs)
51 return web.RequestHandler.render(*args, **kwargs)
52
52
53 class AuthenticatedHandler(RequestHandler):
53 class AuthenticatedHandler(RequestHandler):
54 """A RequestHandler with an authenticated user."""
54 """A RequestHandler with an authenticated user."""
55
55
56 def clear_login_cookie(self):
56 def clear_login_cookie(self):
57 self.clear_cookie(self.cookie_name)
57 self.clear_cookie(self.cookie_name)
58
58
59 def get_current_user(self):
59 def get_current_user(self):
60 user_id = self.get_secure_cookie(self.cookie_name)
60 user_id = self.get_secure_cookie(self.cookie_name)
61 # For now the user_id should not return empty, but it could eventually
61 # For now the user_id should not return empty, but it could eventually
62 if user_id == '':
62 if user_id == '':
63 user_id = 'anonymous'
63 user_id = 'anonymous'
64 if user_id is None:
64 if user_id is None:
65 # prevent extra Invalid cookie sig warnings:
65 # prevent extra Invalid cookie sig warnings:
66 self.clear_login_cookie()
66 self.clear_login_cookie()
67 if not self.login_available:
67 if not self.login_available:
68 user_id = 'anonymous'
68 user_id = 'anonymous'
69 return user_id
69 return user_id
70
70
71 @property
71 @property
72 def cookie_name(self):
72 def cookie_name(self):
73 default_cookie_name = 'username-{host}'.format(
73 default_cookie_name = 'username-{host}'.format(
74 host=self.request.host,
74 host=self.request.host,
75 ).replace(':', '-')
75 ).replace(':', '-')
76 return self.settings.get('cookie_name', default_cookie_name)
76 return self.settings.get('cookie_name', default_cookie_name)
77
77
78 @property
78 @property
79 def password(self):
79 def password(self):
80 """our password"""
80 """our password"""
81 return self.settings.get('password', '')
81 return self.settings.get('password', '')
82
82
83 @property
83 @property
84 def logged_in(self):
84 def logged_in(self):
85 """Is a user currently logged in?
85 """Is a user currently logged in?
86
86
87 """
87 """
88 user = self.get_current_user()
88 user = self.get_current_user()
89 return (user and not user == 'anonymous')
89 return (user and not user == 'anonymous')
90
90
91 @property
91 @property
92 def login_available(self):
92 def login_available(self):
93 """May a user proceed to log in?
93 """May a user proceed to log in?
94
94
95 This returns True if login capability is available, irrespective of
95 This returns True if login capability is available, irrespective of
96 whether the user is already logged in or not.
96 whether the user is already logged in or not.
97
97
98 """
98 """
99 return bool(self.settings.get('password', ''))
99 return bool(self.settings.get('password', ''))
100
100
101
101
102 class IPythonHandler(AuthenticatedHandler):
102 class IPythonHandler(AuthenticatedHandler):
103 """IPython-specific extensions to authenticated handling
103 """IPython-specific extensions to authenticated handling
104
104
105 Mostly property shortcuts to IPython-specific settings.
105 Mostly property shortcuts to IPython-specific settings.
106 """
106 """
107
107
108 @property
108 @property
109 def config(self):
109 def config(self):
110 return self.settings.get('config', None)
110 return self.settings.get('config', None)
111
111
112 @property
112 @property
113 def log(self):
113 def log(self):
114 """use the IPython log by default, falling back on tornado's logger"""
114 """use the IPython log by default, falling back on tornado's logger"""
115 if Application.initialized():
115 if Application.initialized():
116 return Application.instance().log
116 return Application.instance().log
117 else:
117 else:
118 return app_log
118 return app_log
119
119
120 @property
120 @property
121 def use_less(self):
121 def use_less(self):
122 """Use less instead of css in templates"""
122 """Use less instead of css in templates"""
123 return self.settings.get('use_less', False)
123 return self.settings.get('use_less', False)
124
124
125 #---------------------------------------------------------------
125 #---------------------------------------------------------------
126 # URLs
126 # URLs
127 #---------------------------------------------------------------
127 #---------------------------------------------------------------
128
128
129 @property
129 @property
130 def ws_url(self):
130 def ws_url(self):
131 """websocket url matching the current request
131 """websocket url matching the current request
132
132
133 By default, this is just `''`, indicating that it should match
133 By default, this is just `''`, indicating that it should match
134 the same host, protocol, port, etc.
134 the same host, protocol, port, etc.
135 """
135 """
136 return self.settings.get('websocket_url', '')
136 return self.settings.get('websocket_url', '')
137
137
138 @property
138 @property
139 def mathjax_url(self):
139 def mathjax_url(self):
140 return self.settings.get('mathjax_url', '')
140 return self.settings.get('mathjax_url', '')
141
141
142 @property
142 @property
143 def base_project_url(self):
143 def base_project_url(self):
144 return self.settings.get('base_project_url', '/')
144 return self.settings.get('base_project_url', '/')
145
145
146 @property
146 @property
147 def base_kernel_url(self):
147 def base_kernel_url(self):
148 return self.settings.get('base_kernel_url', '/')
148 return self.settings.get('base_kernel_url', '/')
149
149
150 #---------------------------------------------------------------
150 #---------------------------------------------------------------
151 # Manager objects
151 # Manager objects
152 #---------------------------------------------------------------
152 #---------------------------------------------------------------
153
153
154 @property
154 @property
155 def kernel_manager(self):
155 def kernel_manager(self):
156 return self.settings['kernel_manager']
156 return self.settings['kernel_manager']
157
157
158 @property
158 @property
159 def notebook_manager(self):
159 def notebook_manager(self):
160 return self.settings['notebook_manager']
160 return self.settings['notebook_manager']
161
161
162 @property
162 @property
163 def cluster_manager(self):
163 def cluster_manager(self):
164 return self.settings['cluster_manager']
164 return self.settings['cluster_manager']
165
165
166 @property
166 @property
167 def session_manager(self):
167 def session_manager(self):
168 return self.settings['session_manager']
168 return self.settings['session_manager']
169
169
170 @property
170 @property
171 def project_dir(self):
171 def project_dir(self):
172 return self.notebook_manager.notebook_dir
172 return self.notebook_manager.notebook_dir
173
173
174 #---------------------------------------------------------------
174 #---------------------------------------------------------------
175 # template rendering
175 # template rendering
176 #---------------------------------------------------------------
176 #---------------------------------------------------------------
177
177
178 def get_template(self, name):
178 def get_template(self, name):
179 """Return the jinja template object for a given name"""
179 """Return the jinja template object for a given name"""
180 return self.settings['jinja2_env'].get_template(name)
180 return self.settings['jinja2_env'].get_template(name)
181
181
182 def render_template(self, name, **ns):
182 def render_template(self, name, **ns):
183 ns.update(self.template_namespace)
183 ns.update(self.template_namespace)
184 template = self.get_template(name)
184 template = self.get_template(name)
185 return template.render(**ns)
185 return template.render(**ns)
186
186
187 @property
187 @property
188 def template_namespace(self):
188 def template_namespace(self):
189 return dict(
189 return dict(
190 base_project_url=self.base_project_url,
190 base_project_url=self.base_project_url,
191 base_kernel_url=self.base_kernel_url,
191 base_kernel_url=self.base_kernel_url,
192 logged_in=self.logged_in,
192 logged_in=self.logged_in,
193 login_available=self.login_available,
193 login_available=self.login_available,
194 use_less=self.use_less,
194 use_less=self.use_less,
195 )
195 )
196
196
197 def get_json_body(self):
197 def get_json_body(self):
198 """Return the body of the request as JSON data."""
198 """Return the body of the request as JSON data."""
199 if not self.request.body:
199 if not self.request.body:
200 return None
200 return None
201 # Do we need to call body.decode('utf-8') here?
201 # Do we need to call body.decode('utf-8') here?
202 body = self.request.body.strip().decode(u'utf-8')
202 body = self.request.body.strip().decode(u'utf-8')
203 try:
203 try:
204 model = json.loads(body)
204 model = json.loads(body)
205 except Exception:
205 except Exception:
206 self.log.debug("Bad JSON: %r", body)
206 self.log.debug("Bad JSON: %r", body)
207 self.log.error("Couldn't parse JSON", exc_info=True)
207 self.log.error("Couldn't parse JSON", exc_info=True)
208 raise web.HTTPError(400, u'Invalid JSON in body of request')
208 raise web.HTTPError(400, u'Invalid JSON in body of request')
209 return model
209 return model
210
210
211
211
212 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
212 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
213 """static files should only be accessible when logged in"""
213 """static files should only be accessible when logged in"""
214
214
215 @web.authenticated
215 @web.authenticated
216 def get(self, path):
216 def get(self, path):
217 if os.path.splitext(path)[1] == '.ipynb':
217 if os.path.splitext(path)[1] == '.ipynb':
218 name = os.path.basename(path)
218 name = os.path.basename(path)
219 self.set_header('Content-Type', 'application/json')
219 self.set_header('Content-Type', 'application/json')
220 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
220 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
221
221
222 return web.StaticFileHandler.get(self, path)
222 return web.StaticFileHandler.get(self, path)
223
223
224 def compute_etag(self):
225 return None
226
224 def validate_absolute_path(self, root, absolute_path):
227 def validate_absolute_path(self, root, absolute_path):
225 """Validate and return the absolute path.
228 """Validate and return the absolute path.
226
229
227 Requires tornado 3.1
230 Requires tornado 3.1
228
231
229 Adding to tornado's own handling, forbids the serving of hidden files.
232 Adding to tornado's own handling, forbids the serving of hidden files.
230 """
233 """
231 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
234 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
232 abs_root = os.path.abspath(root)
235 abs_root = os.path.abspath(root)
233 self.forbid_hidden(abs_root, abs_path)
236 self.forbid_hidden(abs_root, abs_path)
234 return abs_path
237 return abs_path
235
238
236 def forbid_hidden(self, absolute_root, absolute_path):
239 def forbid_hidden(self, absolute_root, absolute_path):
237 """Raise 403 if a file is hidden or contained in a hidden directory.
240 """Raise 403 if a file is hidden or contained in a hidden directory.
238
241
239 Hidden is determined by either name starting with '.'
242 Hidden is determined by either name starting with '.'
240 or the UF_HIDDEN flag as reported by stat
243 or the UF_HIDDEN flag as reported by stat
241 """
244 """
242 inside_root = absolute_path[len(absolute_root):]
245 inside_root = absolute_path[len(absolute_root):]
243 if any(part.startswith('.') for part in inside_root.split(os.sep)):
246 if any(part.startswith('.') for part in inside_root.split(os.sep)):
244 raise web.HTTPError(403)
247 raise web.HTTPError(403)
245
248
246 # check UF_HIDDEN on any location up to root
249 # check UF_HIDDEN on any location up to root
247 path = absolute_path
250 path = absolute_path
248 while path and path.startswith(absolute_root) and path != absolute_root:
251 while path and path.startswith(absolute_root) and path != absolute_root:
249 st = os.stat(path)
252 st = os.stat(path)
250 if getattr(st, 'st_flags', 0) & UF_HIDDEN:
253 if getattr(st, 'st_flags', 0) & UF_HIDDEN:
251 raise web.HTTPError(403)
254 raise web.HTTPError(403)
252 path = os.path.dirname(path)
255 path = os.path.dirname(path)
253
256
254 return absolute_path
257 return absolute_path
255
258
256
259
257 def json_errors(method):
260 def json_errors(method):
258 """Decorate methods with this to return GitHub style JSON errors.
261 """Decorate methods with this to return GitHub style JSON errors.
259
262
260 This should be used on any JSON API on any handler method that can raise HTTPErrors.
263 This should be used on any JSON API on any handler method that can raise HTTPErrors.
261
264
262 This will grab the latest HTTPError exception using sys.exc_info
265 This will grab the latest HTTPError exception using sys.exc_info
263 and then:
266 and then:
264
267
265 1. Set the HTTP status code based on the HTTPError
268 1. Set the HTTP status code based on the HTTPError
266 2. Create and return a JSON body with a message field describing
269 2. Create and return a JSON body with a message field describing
267 the error in a human readable form.
270 the error in a human readable form.
268 """
271 """
269 @functools.wraps(method)
272 @functools.wraps(method)
270 def wrapper(self, *args, **kwargs):
273 def wrapper(self, *args, **kwargs):
271 try:
274 try:
272 result = method(self, *args, **kwargs)
275 result = method(self, *args, **kwargs)
273 except web.HTTPError as e:
276 except web.HTTPError as e:
274 status = e.status_code
277 status = e.status_code
275 message = e.log_message
278 message = e.log_message
276 self.set_status(e.status_code)
279 self.set_status(e.status_code)
277 self.finish(json.dumps(dict(message=message)))
280 self.finish(json.dumps(dict(message=message)))
278 except Exception:
281 except Exception:
279 self.log.error("Unhandled error in API request", exc_info=True)
282 self.log.error("Unhandled error in API request", exc_info=True)
280 status = 500
283 status = 500
281 message = "Unknown server error"
284 message = "Unknown server error"
282 t, value, tb = sys.exc_info()
285 t, value, tb = sys.exc_info()
283 self.set_status(status)
286 self.set_status(status)
284 tb_text = ''.join(traceback.format_exception(t, value, tb))
287 tb_text = ''.join(traceback.format_exception(t, value, tb))
285 reply = dict(message=message, traceback=tb_text)
288 reply = dict(message=message, traceback=tb_text)
286 self.finish(json.dumps(reply))
289 self.finish(json.dumps(reply))
287 else:
290 else:
288 return result
291 return result
289 return wrapper
292 return wrapper
290
293
291
294
292
295
293 #-----------------------------------------------------------------------------
296 #-----------------------------------------------------------------------------
294 # File handler
297 # File handler
295 #-----------------------------------------------------------------------------
298 #-----------------------------------------------------------------------------
296
299
297 # to minimize subclass changes:
300 # to minimize subclass changes:
298 HTTPError = web.HTTPError
301 HTTPError = web.HTTPError
299
302
300 class FileFindHandler(web.StaticFileHandler):
303 class FileFindHandler(web.StaticFileHandler):
301 """subclass of StaticFileHandler for serving files from a search path"""
304 """subclass of StaticFileHandler for serving files from a search path"""
302
305
303 # cache search results, don't search for files more than once
306 # cache search results, don't search for files more than once
304 _static_paths = {}
307 _static_paths = {}
305
308
306 def initialize(self, path, default_filename=None):
309 def initialize(self, path, default_filename=None):
307 if isinstance(path, basestring):
310 if isinstance(path, basestring):
308 path = [path]
311 path = [path]
309
312
310 self.root = tuple(
313 self.root = tuple(
311 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
314 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
312 )
315 )
313 self.default_filename = default_filename
316 self.default_filename = default_filename
314
317
318 def compute_etag(self):
319 return None
320
315 @classmethod
321 @classmethod
316 def get_absolute_path(cls, roots, path):
322 def get_absolute_path(cls, roots, path):
317 """locate a file to serve on our static file search path"""
323 """locate a file to serve on our static file search path"""
318 with cls._lock:
324 with cls._lock:
319 if path in cls._static_paths:
325 if path in cls._static_paths:
320 return cls._static_paths[path]
326 return cls._static_paths[path]
321 try:
327 try:
322 abspath = os.path.abspath(filefind(path, roots))
328 abspath = os.path.abspath(filefind(path, roots))
323 except IOError:
329 except IOError:
324 # empty string should always give exists=False
330 # empty string should always give exists=False
325 return ''
331 return ''
326
332
327 cls._static_paths[path] = abspath
333 cls._static_paths[path] = abspath
328 return abspath
334 return abspath
329
335
330 def validate_absolute_path(self, root, absolute_path):
336 def validate_absolute_path(self, root, absolute_path):
331 """check if the file should be served (raises 404, 403, etc.)"""
337 """check if the file should be served (raises 404, 403, etc.)"""
332 for root in self.root:
338 for root in self.root:
333 if (absolute_path + os.sep).startswith(root):
339 if (absolute_path + os.sep).startswith(root):
334 break
340 break
335
341
336 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
342 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
337
343
338
344
339 class TrailingSlashHandler(web.RequestHandler):
345 class TrailingSlashHandler(web.RequestHandler):
340 """Simple redirect handler that strips trailing slashes
346 """Simple redirect handler that strips trailing slashes
341
347
342 This should be the first, highest priority handler.
348 This should be the first, highest priority handler.
343 """
349 """
344
350
345 SUPPORTED_METHODS = ['GET']
351 SUPPORTED_METHODS = ['GET']
346
352
347 def get(self):
353 def get(self):
348 self.redirect(self.request.uri.rstrip('/'))
354 self.redirect(self.request.uri.rstrip('/'))
349
355
350 #-----------------------------------------------------------------------------
356 #-----------------------------------------------------------------------------
351 # URL to handler mappings
357 # URL to handler mappings
352 #-----------------------------------------------------------------------------
358 #-----------------------------------------------------------------------------
353
359
354
360
355 default_handlers = [
361 default_handlers = [
356 (r".*/", TrailingSlashHandler)
362 (r".*/", TrailingSlashHandler)
357 ]
363 ]
General Comments 0
You need to be logged in to leave comments. Login now