##// END OF EJS Templates
Adding JSON error handling and fixing location headers.
Brian E. Granger -
Show More
@@ -1,419 +1,457 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 datetime
21 21 import email.utils
22 import functools
22 23 import hashlib
24 import json
23 25 import logging
24 26 import mimetypes
25 27 import os
26 28 import stat
29 import sys
27 30 import threading
28 31
29 32 from tornado import web
30 33 from tornado import websocket
31 34
32 35 try:
33 36 from tornado.log import app_log
34 37 except ImportError:
35 38 app_log = logging.getLogger()
36 39
37 40 from IPython.config import Application
38 41 from IPython.external.decorator import decorator
39 42 from IPython.utils.path import filefind
43 from IPython.utils.jsonutil import date_default
40 44
41 45 #-----------------------------------------------------------------------------
42 46 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
43 47 #-----------------------------------------------------------------------------
44 48
45 49 # Google Chrome, as of release 16, changed its websocket protocol number. The
46 50 # parts tornado cares about haven't really changed, so it's OK to continue
47 51 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
48 52 # version as of Oct 30/2011) the version check fails, see the issue report:
49 53
50 54 # https://github.com/facebook/tornado/issues/385
51 55
52 56 # This issue has been fixed in Tornado post 2.1.1:
53 57
54 58 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
55 59
56 60 # Here we manually apply the same patch as above so that users of IPython can
57 61 # continue to work with an officially released Tornado. We make the
58 62 # monkeypatch version check as narrow as possible to limit its effects; once
59 63 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
60 64
61 65 import tornado
62 66
63 67 if tornado.version_info <= (2,1,1):
64 68
65 69 def _execute(self, transforms, *args, **kwargs):
66 70 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
67 71
68 72 self.open_args = args
69 73 self.open_kwargs = kwargs
70 74
71 75 # The difference between version 8 and 13 is that in 8 the
72 76 # client sends a "Sec-Websocket-Origin" header and in 13 it's
73 77 # simply "Origin".
74 78 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
75 79 self.ws_connection = WebSocketProtocol8(self)
76 80 self.ws_connection.accept_connection()
77 81
78 82 elif self.request.headers.get("Sec-WebSocket-Version"):
79 83 self.stream.write(tornado.escape.utf8(
80 84 "HTTP/1.1 426 Upgrade Required\r\n"
81 85 "Sec-WebSocket-Version: 8\r\n\r\n"))
82 86 self.stream.close()
83 87
84 88 else:
85 89 self.ws_connection = WebSocketProtocol76(self)
86 90 self.ws_connection.accept_connection()
87 91
88 92 websocket.WebSocketHandler._execute = _execute
89 93 del _execute
90 94
91 95
92 96 #-----------------------------------------------------------------------------
93 97 # Top-level handlers
94 98 #-----------------------------------------------------------------------------
95 99
96 100 class RequestHandler(web.RequestHandler):
97 101 """RequestHandler with default variable setting."""
98 102
99 103 def render(*args, **kwargs):
100 104 kwargs.setdefault('message', '')
101 105 return web.RequestHandler.render(*args, **kwargs)
102 106
103 107 class AuthenticatedHandler(RequestHandler):
104 108 """A RequestHandler with an authenticated user."""
105 109
106 110 def clear_login_cookie(self):
107 111 self.clear_cookie(self.cookie_name)
108 112
109 113 def get_current_user(self):
110 114 user_id = self.get_secure_cookie(self.cookie_name)
111 115 # For now the user_id should not return empty, but it could eventually
112 116 if user_id == '':
113 117 user_id = 'anonymous'
114 118 if user_id is None:
115 119 # prevent extra Invalid cookie sig warnings:
116 120 self.clear_login_cookie()
117 121 if not self.login_available:
118 122 user_id = 'anonymous'
119 123 return user_id
120 124
121 125 @property
122 126 def cookie_name(self):
123 127 default_cookie_name = 'username-{host}'.format(
124 128 host=self.request.host,
125 129 ).replace(':', '-')
126 130 return self.settings.get('cookie_name', default_cookie_name)
127 131
128 132 @property
129 133 def password(self):
130 134 """our password"""
131 135 return self.settings.get('password', '')
132 136
133 137 @property
134 138 def logged_in(self):
135 139 """Is a user currently logged in?
136 140
137 141 """
138 142 user = self.get_current_user()
139 143 return (user and not user == 'anonymous')
140 144
141 145 @property
142 146 def login_available(self):
143 147 """May a user proceed to log in?
144 148
145 149 This returns True if login capability is available, irrespective of
146 150 whether the user is already logged in or not.
147 151
148 152 """
149 153 return bool(self.settings.get('password', ''))
150 154
151 155
152 156 class IPythonHandler(AuthenticatedHandler):
153 157 """IPython-specific extensions to authenticated handling
154 158
155 159 Mostly property shortcuts to IPython-specific settings.
156 160 """
157 161
158 162 @property
159 163 def config(self):
160 164 return self.settings.get('config', None)
161 165
162 166 @property
163 167 def log(self):
164 168 """use the IPython log by default, falling back on tornado's logger"""
165 169 if Application.initialized():
166 170 return Application.instance().log
167 171 else:
168 172 return app_log
169 173
170 174 @property
171 175 def use_less(self):
172 176 """Use less instead of css in templates"""
173 177 return self.settings.get('use_less', False)
174 178
175 179 #---------------------------------------------------------------
176 180 # URLs
177 181 #---------------------------------------------------------------
178 182
179 183 @property
180 184 def ws_url(self):
181 185 """websocket url matching the current request
182 186
183 187 By default, this is just `''`, indicating that it should match
184 188 the same host, protocol, port, etc.
185 189 """
186 190 return self.settings.get('websocket_url', '')
187 191
188 192 @property
189 193 def mathjax_url(self):
190 194 return self.settings.get('mathjax_url', '')
191 195
192 196 @property
193 197 def base_project_url(self):
194 198 return self.settings.get('base_project_url', '/')
195 199
196 200 @property
197 201 def base_kernel_url(self):
198 202 return self.settings.get('base_kernel_url', '/')
199 203
200 204 #---------------------------------------------------------------
201 205 # Manager objects
202 206 #---------------------------------------------------------------
203 207
204 208 @property
205 209 def kernel_manager(self):
206 210 return self.settings['kernel_manager']
207 211
208 212 @property
209 213 def notebook_manager(self):
210 214 return self.settings['notebook_manager']
211 215
212 216 @property
213 217 def cluster_manager(self):
214 218 return self.settings['cluster_manager']
215 219
216 220 @property
217 221 def session_manager(self):
218 222 return self.settings['session_manager']
219 223
220 224 @property
221 225 def project(self):
222 226 return self.notebook_manager.notebook_dir
223 227
224 228 #---------------------------------------------------------------
225 229 # template rendering
226 230 #---------------------------------------------------------------
227 231
228 232 def get_template(self, name):
229 233 """Return the jinja template object for a given name"""
230 234 return self.settings['jinja2_env'].get_template(name)
231 235
232 236 def render_template(self, name, **ns):
233 237 ns.update(self.template_namespace)
234 238 template = self.get_template(name)
235 239 return template.render(**ns)
236 240
237 241 @property
238 242 def template_namespace(self):
239 243 return dict(
240 244 base_project_url=self.base_project_url,
241 245 base_kernel_url=self.base_kernel_url,
242 246 logged_in=self.logged_in,
243 247 login_available=self.login_available,
244 248 use_less=self.use_less,
245 249 )
246 250
251
247 252 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
248 253 """static files should only be accessible when logged in"""
249 254
250 255 @web.authenticated
251 256 def get(self, path):
252 257 return web.StaticFileHandler.get(self, path)
253 258
254 259
260 def json_errors(method):
261 """Decorate methods with this to return GitHub style JSON errors.
262
263 This should be used on any handler method that can raise HTTPErrors.
264
265 This will grab the latest HTTPError exception using sys.exc_info
266 and then:
267
268 1. Set the HTTP status code based on the HTTPError
269 2. Create and return a JSON body with a message field describing
270 the error in a human readable form.
271 """
272 @functools.wraps(method)
273 def wrapper(self, *args, **kwargs):
274 try:
275 result = method(self, *args, **kwargs)
276 except:
277 t, value, tb = sys.exc_info()
278 if isinstance(value, web.HTTPError):
279 status = value.status_code
280 message = value.log_message
281 else:
282 status = 400
283 message = u"Unknown server error"
284 self.set_status(status)
285 reply = dict(message=message)
286 self.finish(json.dumps(reply, default=date_default))
287 else:
288 return result
289 return wrapper
290
291
292
255 293 #-----------------------------------------------------------------------------
256 294 # File handler
257 295 #-----------------------------------------------------------------------------
258 296
259 297 # to minimize subclass changes:
260 298 HTTPError = web.HTTPError
261 299
262 300 class FileFindHandler(web.StaticFileHandler):
263 301 """subclass of StaticFileHandler for serving files from a search path"""
264 302
265 303 _static_paths = {}
266 304 # _lock is needed for tornado < 2.2.0 compat
267 305 _lock = threading.Lock() # protects _static_hashes
268 306
269 307 def initialize(self, path, default_filename=None):
270 308 if isinstance(path, basestring):
271 309 path = [path]
272 310 self.roots = tuple(
273 311 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
274 312 )
275 313 self.default_filename = default_filename
276 314
277 315 @classmethod
278 316 def locate_file(cls, path, roots):
279 317 """locate a file to serve on our static file search path"""
280 318 with cls._lock:
281 319 if path in cls._static_paths:
282 320 return cls._static_paths[path]
283 321 try:
284 322 abspath = os.path.abspath(filefind(path, roots))
285 323 except IOError:
286 324 # empty string should always give exists=False
287 325 return ''
288 326
289 327 # os.path.abspath strips a trailing /
290 328 # it needs to be temporarily added back for requests to root/
291 329 if not (abspath + os.path.sep).startswith(roots):
292 330 raise HTTPError(403, "%s is not in root static directory", path)
293 331
294 332 cls._static_paths[path] = abspath
295 333 return abspath
296 334
297 335 def get(self, path, include_body=True):
298 336 path = self.parse_url_path(path)
299 337
300 338 # begin subclass override
301 339 abspath = self.locate_file(path, self.roots)
302 340 # end subclass override
303 341
304 342 if os.path.isdir(abspath) and self.default_filename is not None:
305 343 # need to look at the request.path here for when path is empty
306 344 # but there is some prefix to the path that was already
307 345 # trimmed by the routing
308 346 if not self.request.path.endswith("/"):
309 347 self.redirect(self.request.path + "/")
310 348 return
311 349 abspath = os.path.join(abspath, self.default_filename)
312 350 if not os.path.exists(abspath):
313 351 raise HTTPError(404)
314 352 if not os.path.isfile(abspath):
315 353 raise HTTPError(403, "%s is not a file", path)
316 354
317 355 stat_result = os.stat(abspath)
318 356 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
319 357
320 358 self.set_header("Last-Modified", modified)
321 359
322 360 mime_type, encoding = mimetypes.guess_type(abspath)
323 361 if mime_type:
324 362 self.set_header("Content-Type", mime_type)
325 363
326 364 cache_time = self.get_cache_time(path, modified, mime_type)
327 365
328 366 if cache_time > 0:
329 367 self.set_header("Expires", datetime.datetime.utcnow() + \
330 368 datetime.timedelta(seconds=cache_time))
331 369 self.set_header("Cache-Control", "max-age=" + str(cache_time))
332 370 else:
333 371 self.set_header("Cache-Control", "public")
334 372
335 373 self.set_extra_headers(path)
336 374
337 375 # Check the If-Modified-Since, and don't send the result if the
338 376 # content has not been modified
339 377 ims_value = self.request.headers.get("If-Modified-Since")
340 378 if ims_value is not None:
341 379 date_tuple = email.utils.parsedate(ims_value)
342 380 if_since = datetime.datetime(*date_tuple[:6])
343 381 if if_since >= modified:
344 382 self.set_status(304)
345 383 return
346 384
347 385 with open(abspath, "rb") as file:
348 386 data = file.read()
349 387 hasher = hashlib.sha1()
350 388 hasher.update(data)
351 389 self.set_header("Etag", '"%s"' % hasher.hexdigest())
352 390 if include_body:
353 391 self.write(data)
354 392 else:
355 393 assert self.request.method == "HEAD"
356 394 self.set_header("Content-Length", len(data))
357 395
358 396 @classmethod
359 397 def get_version(cls, settings, path):
360 398 """Generate the version string to be used in static URLs.
361 399
362 400 This method may be overridden in subclasses (but note that it
363 401 is a class method rather than a static method). The default
364 402 implementation uses a hash of the file's contents.
365 403
366 404 ``settings`` is the `Application.settings` dictionary and ``path``
367 405 is the relative location of the requested asset on the filesystem.
368 406 The returned value should be a string, or ``None`` if no version
369 407 could be determined.
370 408 """
371 409 # begin subclass override:
372 410 static_paths = settings['static_path']
373 411 if isinstance(static_paths, basestring):
374 412 static_paths = [static_paths]
375 413 roots = tuple(
376 414 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
377 415 )
378 416
379 417 try:
380 418 abs_path = filefind(path, roots)
381 419 except IOError:
382 420 app_log.error("Could not find static file %r", path)
383 421 return None
384 422
385 423 # end subclass override
386 424
387 425 with cls._lock:
388 426 hashes = cls._static_hashes
389 427 if abs_path not in hashes:
390 428 try:
391 429 f = open(abs_path, "rb")
392 430 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
393 431 f.close()
394 432 except Exception:
395 433 app_log.error("Could not open static file %r", path)
396 434 hashes[abs_path] = None
397 435 hsh = hashes.get(abs_path)
398 436 if hsh:
399 437 return hsh[:5]
400 438 return None
401 439
402 440
403 441 def parse_url_path(self, url_path):
404 442 """Converts a static URL path into a filesystem path.
405 443
406 444 ``url_path`` is the path component of the URL with
407 445 ``static_url_prefix`` removed. The return value should be
408 446 filesystem path relative to ``static_path``.
409 447 """
410 448 if os.path.sep != "/":
411 449 url_path = url_path.replace("/", os.path.sep)
412 450 return url_path
413 451
414 452 #-----------------------------------------------------------------------------
415 453 # URL to handler mappings
416 454 #-----------------------------------------------------------------------------
417 455
418 456
419 457 default_handlers = []
General Comments 0
You need to be logged in to leave comments. Login now