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