##// END OF EJS Templates
Backport PR #6318: use write_error instead of get_error_html...
Thomas Kluyver -
Show More
@@ -1,432 +1,436 b''
1 1 """Base Tornado handlers for the notebook.
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19
20 20 import functools
21 21 import json
22 22 import logging
23 23 import os
24 24 import re
25 25 import sys
26 26 import traceback
27 27 try:
28 28 # py3
29 29 from http.client import responses
30 30 except ImportError:
31 31 from httplib import responses
32 32
33 33 from jinja2 import TemplateNotFound
34 34 from tornado import web
35 35
36 36 try:
37 37 from tornado.log import app_log
38 38 except ImportError:
39 39 app_log = logging.getLogger()
40 40
41 41 from IPython.config import Application
42 42 from IPython.utils.path import filefind
43 43 from IPython.utils.py3compat import string_types
44 44 from IPython.html.utils import is_hidden
45 45
46 46 #-----------------------------------------------------------------------------
47 47 # Top-level handlers
48 48 #-----------------------------------------------------------------------------
49 49 non_alphanum = re.compile(r'[^A-Za-z0-9]')
50 50
51 51 class AuthenticatedHandler(web.RequestHandler):
52 52 """A RequestHandler with an authenticated user."""
53 53
54 54 def set_default_headers(self):
55 55 headers = self.settings.get('headers', {})
56 56
57 57 if "X-Frame-Options" not in headers:
58 58 headers["X-Frame-Options"] = "SAMEORIGIN"
59 59
60 60 for header_name,value in headers.items() :
61 61 try:
62 62 self.set_header(header_name, value)
63 63 except Exception:
64 64 # tornado raise Exception (not a subclass)
65 65 # if method is unsupported (websocket and Access-Control-Allow-Origin
66 66 # for example, so just ignore)
67 67 pass
68 68
69 69 def clear_login_cookie(self):
70 70 self.clear_cookie(self.cookie_name)
71 71
72 72 def get_current_user(self):
73 73 user_id = self.get_secure_cookie(self.cookie_name)
74 74 # For now the user_id should not return empty, but it could eventually
75 75 if user_id == '':
76 76 user_id = 'anonymous'
77 77 if user_id is None:
78 78 # prevent extra Invalid cookie sig warnings:
79 79 self.clear_login_cookie()
80 80 if not self.login_available:
81 81 user_id = 'anonymous'
82 82 return user_id
83 83
84 84 @property
85 85 def cookie_name(self):
86 86 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
87 87 self.request.host
88 88 ))
89 89 return self.settings.get('cookie_name', default_cookie_name)
90 90
91 91 @property
92 92 def password(self):
93 93 """our password"""
94 94 return self.settings.get('password', '')
95 95
96 96 @property
97 97 def logged_in(self):
98 98 """Is a user currently logged in?
99 99
100 100 """
101 101 user = self.get_current_user()
102 102 return (user and not user == 'anonymous')
103 103
104 104 @property
105 105 def login_available(self):
106 106 """May a user proceed to log in?
107 107
108 108 This returns True if login capability is available, irrespective of
109 109 whether the user is already logged in or not.
110 110
111 111 """
112 112 return bool(self.settings.get('password', ''))
113 113
114 114
115 115 class IPythonHandler(AuthenticatedHandler):
116 116 """IPython-specific extensions to authenticated handling
117 117
118 118 Mostly property shortcuts to IPython-specific settings.
119 119 """
120 120
121 121 @property
122 122 def config(self):
123 123 return self.settings.get('config', None)
124 124
125 125 @property
126 126 def log(self):
127 127 """use the IPython log by default, falling back on tornado's logger"""
128 128 if Application.initialized():
129 129 return Application.instance().log
130 130 else:
131 131 return app_log
132 132
133 133 #---------------------------------------------------------------
134 134 # URLs
135 135 #---------------------------------------------------------------
136 136
137 137 @property
138 138 def mathjax_url(self):
139 139 return self.settings.get('mathjax_url', '')
140 140
141 141 @property
142 142 def base_url(self):
143 143 return self.settings.get('base_url', '/')
144 144
145 145 #---------------------------------------------------------------
146 146 # Manager objects
147 147 #---------------------------------------------------------------
148 148
149 149 @property
150 150 def kernel_manager(self):
151 151 return self.settings['kernel_manager']
152 152
153 153 @property
154 154 def notebook_manager(self):
155 155 return self.settings['notebook_manager']
156 156
157 157 @property
158 158 def cluster_manager(self):
159 159 return self.settings['cluster_manager']
160 160
161 161 @property
162 162 def session_manager(self):
163 163 return self.settings['session_manager']
164 164
165 165 @property
166 166 def project_dir(self):
167 167 return self.notebook_manager.notebook_dir
168 168
169 169 #---------------------------------------------------------------
170 170 # CORS
171 171 #---------------------------------------------------------------
172 172
173 173 @property
174 174 def allow_origin(self):
175 175 """Normal Access-Control-Allow-Origin"""
176 176 return self.settings.get('allow_origin', '')
177 177
178 178 @property
179 179 def allow_origin_pat(self):
180 180 """Regular expression version of allow_origin"""
181 181 return self.settings.get('allow_origin_pat', None)
182 182
183 183 @property
184 184 def allow_credentials(self):
185 185 """Whether to set Access-Control-Allow-Credentials"""
186 186 return self.settings.get('allow_credentials', False)
187 187
188 188 def set_default_headers(self):
189 189 """Add CORS headers, if defined"""
190 190 super(IPythonHandler, self).set_default_headers()
191 191 if self.allow_origin:
192 192 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
193 193 elif self.allow_origin_pat:
194 194 origin = self.get_origin()
195 195 if origin and self.allow_origin_pat.match(origin):
196 196 self.set_header("Access-Control-Allow-Origin", origin)
197 197 if self.allow_credentials:
198 198 self.set_header("Access-Control-Allow-Credentials", 'true')
199 199
200 200 def get_origin(self):
201 201 # Handle WebSocket Origin naming convention differences
202 202 # The difference between version 8 and 13 is that in 8 the
203 203 # client sends a "Sec-Websocket-Origin" header and in 13 it's
204 204 # simply "Origin".
205 205 if "Origin" in self.request.headers:
206 206 origin = self.request.headers.get("Origin")
207 207 else:
208 208 origin = self.request.headers.get("Sec-Websocket-Origin", None)
209 209 return origin
210 210
211 211 #---------------------------------------------------------------
212 212 # template rendering
213 213 #---------------------------------------------------------------
214 214
215 215 def get_template(self, name):
216 216 """Return the jinja template object for a given name"""
217 217 return self.settings['jinja2_env'].get_template(name)
218 218
219 219 def render_template(self, name, **ns):
220 220 ns.update(self.template_namespace)
221 221 template = self.get_template(name)
222 222 return template.render(**ns)
223 223
224 224 @property
225 225 def template_namespace(self):
226 226 return dict(
227 227 base_url=self.base_url,
228 228 logged_in=self.logged_in,
229 229 login_available=self.login_available,
230 230 static_url=self.static_url,
231 231 )
232 232
233 233 def get_json_body(self):
234 234 """Return the body of the request as JSON data."""
235 235 if not self.request.body:
236 236 return None
237 237 # Do we need to call body.decode('utf-8') here?
238 238 body = self.request.body.strip().decode(u'utf-8')
239 239 try:
240 240 model = json.loads(body)
241 241 except Exception:
242 242 self.log.debug("Bad JSON: %r", body)
243 243 self.log.error("Couldn't parse JSON", exc_info=True)
244 244 raise web.HTTPError(400, u'Invalid JSON in body of request')
245 245 return model
246 246
247 def get_error_html(self, status_code, **kwargs):
247 def write_error(self, status_code, **kwargs):
248 248 """render custom error pages"""
249 exception = kwargs.get('exception')
249 exc_info = kwargs.get('exc_info')
250 250 message = ''
251 251 status_message = responses.get(status_code, 'Unknown HTTP Error')
252 if exception:
252 if exc_info:
253 exception = exc_info[1]
253 254 # get the custom message, if defined
254 255 try:
255 256 message = exception.log_message % exception.args
256 257 except Exception:
257 258 pass
258 259
259 260 # construct the custom reason, if defined
260 261 reason = getattr(exception, 'reason', '')
261 262 if reason:
262 263 status_message = reason
263 264
264 265 # build template namespace
265 266 ns = dict(
266 267 status_code=status_code,
267 268 status_message=status_message,
268 269 message=message,
269 270 exception=exception,
270 271 )
271 272
273 self.set_header('Content-Type', 'text/html')
272 274 # render the template
273 275 try:
274 276 html = self.render_template('%s.html' % status_code, **ns)
275 277 except TemplateNotFound:
276 278 self.log.debug("No template for %d", status_code)
277 279 html = self.render_template('error.html', **ns)
278 return html
280
281 self.write(html)
282
279 283
280 284
281 285 class Template404(IPythonHandler):
282 286 """Render our 404 template"""
283 287 def prepare(self):
284 288 raise web.HTTPError(404)
285 289
286 290
287 291 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
288 292 """static files should only be accessible when logged in"""
289 293
290 294 @web.authenticated
291 295 def get(self, path):
292 296 if os.path.splitext(path)[1] == '.ipynb':
293 297 name = os.path.basename(path)
294 298 self.set_header('Content-Type', 'application/json')
295 299 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
296 300
297 301 return web.StaticFileHandler.get(self, path)
298 302
299 303 def compute_etag(self):
300 304 return None
301 305
302 306 def validate_absolute_path(self, root, absolute_path):
303 307 """Validate and return the absolute path.
304 308
305 309 Requires tornado 3.1
306 310
307 311 Adding to tornado's own handling, forbids the serving of hidden files.
308 312 """
309 313 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
310 314 abs_root = os.path.abspath(root)
311 315 if is_hidden(abs_path, abs_root):
312 316 self.log.info("Refusing to serve hidden file, via 404 Error")
313 317 raise web.HTTPError(404)
314 318 return abs_path
315 319
316 320
317 321 def json_errors(method):
318 322 """Decorate methods with this to return GitHub style JSON errors.
319 323
320 324 This should be used on any JSON API on any handler method that can raise HTTPErrors.
321 325
322 326 This will grab the latest HTTPError exception using sys.exc_info
323 327 and then:
324 328
325 329 1. Set the HTTP status code based on the HTTPError
326 330 2. Create and return a JSON body with a message field describing
327 331 the error in a human readable form.
328 332 """
329 333 @functools.wraps(method)
330 334 def wrapper(self, *args, **kwargs):
331 335 try:
332 336 result = method(self, *args, **kwargs)
333 337 except web.HTTPError as e:
334 338 status = e.status_code
335 339 message = e.log_message
336 340 self.log.warn(message)
337 341 self.set_status(e.status_code)
338 342 self.finish(json.dumps(dict(message=message)))
339 343 except Exception:
340 344 self.log.error("Unhandled error in API request", exc_info=True)
341 345 status = 500
342 346 message = "Unknown server error"
343 347 t, value, tb = sys.exc_info()
344 348 self.set_status(status)
345 349 tb_text = ''.join(traceback.format_exception(t, value, tb))
346 350 reply = dict(message=message, traceback=tb_text)
347 351 self.finish(json.dumps(reply))
348 352 else:
349 353 return result
350 354 return wrapper
351 355
352 356
353 357
354 358 #-----------------------------------------------------------------------------
355 359 # File handler
356 360 #-----------------------------------------------------------------------------
357 361
358 362 # to minimize subclass changes:
359 363 HTTPError = web.HTTPError
360 364
361 365 class FileFindHandler(web.StaticFileHandler):
362 366 """subclass of StaticFileHandler for serving files from a search path"""
363 367
364 368 # cache search results, don't search for files more than once
365 369 _static_paths = {}
366 370
367 371 def initialize(self, path, default_filename=None):
368 372 if isinstance(path, string_types):
369 373 path = [path]
370 374
371 375 self.root = tuple(
372 376 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
373 377 )
374 378 self.default_filename = default_filename
375 379
376 380 def compute_etag(self):
377 381 return None
378 382
379 383 @classmethod
380 384 def get_absolute_path(cls, roots, path):
381 385 """locate a file to serve on our static file search path"""
382 386 with cls._lock:
383 387 if path in cls._static_paths:
384 388 return cls._static_paths[path]
385 389 try:
386 390 abspath = os.path.abspath(filefind(path, roots))
387 391 except IOError:
388 392 # IOError means not found
389 393 return ''
390 394
391 395 cls._static_paths[path] = abspath
392 396 return abspath
393 397
394 398 def validate_absolute_path(self, root, absolute_path):
395 399 """check if the file should be served (raises 404, 403, etc.)"""
396 400 if absolute_path == '':
397 401 raise web.HTTPError(404)
398 402
399 403 for root in self.root:
400 404 if (absolute_path + os.sep).startswith(root):
401 405 break
402 406
403 407 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
404 408
405 409
406 410 class TrailingSlashHandler(web.RequestHandler):
407 411 """Simple redirect handler that strips trailing slashes
408 412
409 413 This should be the first, highest priority handler.
410 414 """
411 415
412 416 SUPPORTED_METHODS = ['GET']
413 417
414 418 def get(self):
415 419 self.redirect(self.request.uri.rstrip('/'))
416 420
417 421 #-----------------------------------------------------------------------------
418 422 # URL pattern fragments for re-use
419 423 #-----------------------------------------------------------------------------
420 424
421 425 path_regex = r"(?P<path>(?:/.*)*)"
422 426 notebook_name_regex = r"(?P<name>[^/]+\.ipynb)"
423 427 notebook_path_regex = "%s/%s" % (path_regex, notebook_name_regex)
424 428
425 429 #-----------------------------------------------------------------------------
426 430 # URL to handler mappings
427 431 #-----------------------------------------------------------------------------
428 432
429 433
430 434 default_handlers = [
431 435 (r".*/", TrailingSlashHandler)
432 436 ]
General Comments 0
You need to be logged in to leave comments. Login now