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