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