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