##// END OF EJS Templates
allow draft76 websockets (Safari)...
MinRK -
Show More
@@ -1,630 +1,638
1 1 """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) 2008-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 import logging
20 20 import Cookie
21 21 import time
22 22 import uuid
23 23
24 24 from tornado import web
25 25 from tornado import websocket
26 26
27 27 from zmq.eventloop import ioloop
28 28 from zmq.utils import jsonapi
29 29
30 30 from IPython.external.decorator import decorator
31 31 from IPython.zmq.session import Session
32 32 from IPython.lib.security import passwd_check
33 33
34 34 try:
35 35 from docutils.core import publish_string
36 36 except ImportError:
37 37 publish_string = None
38 38
39 39 #-----------------------------------------------------------------------------
40 40 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
41 41 #-----------------------------------------------------------------------------
42 42
43 43 # Google Chrome, as of release 16, changed its websocket protocol number. The
44 44 # parts tornado cares about haven't really changed, so it's OK to continue
45 45 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
46 46 # version as of Oct 30/2011) the version check fails, see the issue report:
47 47
48 48 # https://github.com/facebook/tornado/issues/385
49 49
50 50 # This issue has been fixed in Tornado post 2.1.1:
51 51
52 52 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
53 53
54 54 # Here we manually apply the same patch as above so that users of IPython can
55 55 # continue to work with an officially released Tornado. We make the
56 56 # monkeypatch version check as narrow as possible to limit its effects; once
57 57 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
58 58
59 59 import tornado
60 60
61 61 if tornado.version_info <= (2,1,1):
62 62
63 63 def _execute(self, transforms, *args, **kwargs):
64 64 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
65 65
66 66 self.open_args = args
67 67 self.open_kwargs = kwargs
68 68
69 69 # The difference between version 8 and 13 is that in 8 the
70 70 # client sends a "Sec-Websocket-Origin" header and in 13 it's
71 71 # simply "Origin".
72 72 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
73 73 self.ws_connection = WebSocketProtocol8(self)
74 74 self.ws_connection.accept_connection()
75 75
76 76 elif self.request.headers.get("Sec-WebSocket-Version"):
77 77 self.stream.write(tornado.escape.utf8(
78 78 "HTTP/1.1 426 Upgrade Required\r\n"
79 79 "Sec-WebSocket-Version: 8\r\n\r\n"))
80 80 self.stream.close()
81 81
82 82 else:
83 83 self.ws_connection = WebSocketProtocol76(self)
84 84 self.ws_connection.accept_connection()
85 85
86 86 websocket.WebSocketHandler._execute = _execute
87 87 del _execute
88 88
89 89 #-----------------------------------------------------------------------------
90 90 # Decorator for disabling read-only handlers
91 91 #-----------------------------------------------------------------------------
92 92
93 93 @decorator
94 94 def not_if_readonly(f, self, *args, **kwargs):
95 95 if self.application.read_only:
96 96 raise web.HTTPError(403, "Notebook server is read-only")
97 97 else:
98 98 return f(self, *args, **kwargs)
99 99
100 100 @decorator
101 101 def authenticate_unless_readonly(f, self, *args, **kwargs):
102 102 """authenticate this page *unless* readonly view is active.
103 103
104 104 In read-only mode, the notebook list and print view should
105 105 be accessible without authentication.
106 106 """
107 107
108 108 @web.authenticated
109 109 def auth_f(self, *args, **kwargs):
110 110 return f(self, *args, **kwargs)
111 111 if self.application.read_only:
112 112 return f(self, *args, **kwargs)
113 113 else:
114 114 return auth_f(self, *args, **kwargs)
115 115
116 116 #-----------------------------------------------------------------------------
117 117 # Top-level handlers
118 118 #-----------------------------------------------------------------------------
119 119
120 120 class RequestHandler(web.RequestHandler):
121 121 """RequestHandler with default variable setting."""
122 122
123 123 def render(*args, **kwargs):
124 124 kwargs.setdefault('message', '')
125 125 return web.RequestHandler.render(*args, **kwargs)
126 126
127 127 class AuthenticatedHandler(RequestHandler):
128 128 """A RequestHandler with an authenticated user."""
129 129
130 130 def get_current_user(self):
131 131 user_id = self.get_secure_cookie("username")
132 132 # For now the user_id should not return empty, but it could eventually
133 133 if user_id == '':
134 134 user_id = 'anonymous'
135 135 if user_id is None:
136 136 # prevent extra Invalid cookie sig warnings:
137 137 self.clear_cookie('username')
138 138 if not self.application.password and not self.application.read_only:
139 139 user_id = 'anonymous'
140 140 return user_id
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.application.password)
159 159
160 160 @property
161 161 def read_only(self):
162 162 """Is the notebook read-only?
163 163
164 164 """
165 165 return self.application.read_only
166 166
167 167 @property
168 168 def ws_url(self):
169 169 """websocket url matching the current request
170 170
171 171 turns http[s]://host[:port] into
172 172 ws[s]://host[:port]
173 173 """
174 174 proto = self.request.protocol.replace('http', 'ws')
175 175 return "%s://%s" % (proto, self.request.host)
176 176
177 177
178 178 class ProjectDashboardHandler(AuthenticatedHandler):
179 179
180 180 @authenticate_unless_readonly
181 181 def get(self):
182 182 nbm = self.application.notebook_manager
183 183 project = nbm.notebook_dir
184 184 self.render(
185 185 'projectdashboard.html', project=project,
186 186 base_project_url=u'/', base_kernel_url=u'/',
187 187 read_only=self.read_only,
188 188 logged_in=self.logged_in,
189 189 login_available=self.login_available
190 190 )
191 191
192 192
193 193 class LoginHandler(AuthenticatedHandler):
194 194
195 195 def _render(self, message=None):
196 196 self.render('login.html',
197 197 next=self.get_argument('next', default='/'),
198 198 read_only=self.read_only,
199 199 logged_in=self.logged_in,
200 200 login_available=self.login_available,
201 201 message=message
202 202 )
203 203
204 204 def get(self):
205 205 if self.current_user:
206 206 self.redirect(self.get_argument('next', default='/'))
207 207 else:
208 208 self._render()
209 209
210 210 def post(self):
211 211 pwd = self.get_argument('password', default=u'')
212 212 if self.application.password:
213 213 if passwd_check(self.application.password, pwd):
214 214 self.set_secure_cookie('username', str(uuid.uuid4()))
215 215 else:
216 216 self._render(message={'error': 'Invalid password'})
217 217 return
218 218
219 219 self.redirect(self.get_argument('next', default='/'))
220 220
221 221
222 222 class LogoutHandler(AuthenticatedHandler):
223 223
224 224 def get(self):
225 225 self.clear_cookie('username')
226 226 if self.login_available:
227 227 message = {'info': 'Successfully logged out.'}
228 228 else:
229 229 message = {'warning': 'Cannot log out. Notebook authentication '
230 230 'is disabled.'}
231 231
232 232 self.render('logout.html',
233 233 read_only=self.read_only,
234 234 logged_in=self.logged_in,
235 235 login_available=self.login_available,
236 236 message=message)
237 237
238 238
239 239 class NewHandler(AuthenticatedHandler):
240 240
241 241 @web.authenticated
242 242 def get(self):
243 243 nbm = self.application.notebook_manager
244 244 project = nbm.notebook_dir
245 245 notebook_id = nbm.new_notebook()
246 246 self.render(
247 247 'notebook.html', project=project,
248 248 notebook_id=notebook_id,
249 249 base_project_url=u'/', base_kernel_url=u'/',
250 250 kill_kernel=False,
251 251 read_only=False,
252 252 logged_in=self.logged_in,
253 253 login_available=self.login_available,
254 254 mathjax_url=self.application.ipython_app.mathjax_url,
255 255 )
256 256
257 257
258 258 class NamedNotebookHandler(AuthenticatedHandler):
259 259
260 260 @authenticate_unless_readonly
261 261 def get(self, notebook_id):
262 262 nbm = self.application.notebook_manager
263 263 project = nbm.notebook_dir
264 264 if not nbm.notebook_exists(notebook_id):
265 265 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
266 266
267 267 self.render(
268 268 'notebook.html', project=project,
269 269 notebook_id=notebook_id,
270 270 base_project_url=u'/', base_kernel_url=u'/',
271 271 kill_kernel=False,
272 272 read_only=self.read_only,
273 273 logged_in=self.logged_in,
274 274 login_available=self.login_available,
275 275 mathjax_url=self.application.ipython_app.mathjax_url,
276 276 )
277 277
278 278
279 279 #-----------------------------------------------------------------------------
280 280 # Kernel handlers
281 281 #-----------------------------------------------------------------------------
282 282
283 283
284 284 class MainKernelHandler(AuthenticatedHandler):
285 285
286 286 @web.authenticated
287 287 def get(self):
288 288 km = self.application.kernel_manager
289 289 self.finish(jsonapi.dumps(km.kernel_ids))
290 290
291 291 @web.authenticated
292 292 def post(self):
293 293 km = self.application.kernel_manager
294 294 notebook_id = self.get_argument('notebook', default=None)
295 295 kernel_id = km.start_kernel(notebook_id)
296 296 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
297 297 self.set_header('Location', '/'+kernel_id)
298 298 self.finish(jsonapi.dumps(data))
299 299
300 300
301 301 class KernelHandler(AuthenticatedHandler):
302 302
303 303 SUPPORTED_METHODS = ('DELETE')
304 304
305 305 @web.authenticated
306 306 def delete(self, kernel_id):
307 307 km = self.application.kernel_manager
308 308 km.kill_kernel(kernel_id)
309 309 self.set_status(204)
310 310 self.finish()
311 311
312 312
313 313 class KernelActionHandler(AuthenticatedHandler):
314 314
315 315 @web.authenticated
316 316 def post(self, kernel_id, action):
317 317 km = self.application.kernel_manager
318 318 if action == 'interrupt':
319 319 km.interrupt_kernel(kernel_id)
320 320 self.set_status(204)
321 321 if action == 'restart':
322 322 new_kernel_id = km.restart_kernel(kernel_id)
323 323 data = {'ws_url':self.ws_url,'kernel_id':new_kernel_id}
324 324 self.set_header('Location', '/'+new_kernel_id)
325 325 self.write(jsonapi.dumps(data))
326 326 self.finish()
327 327
328 328
329 329 class ZMQStreamHandler(websocket.WebSocketHandler):
330 330
331 331 def _reserialize_reply(self, msg_list):
332 332 """Reserialize a reply message using JSON.
333 333
334 334 This takes the msg list from the ZMQ socket, unserializes it using
335 335 self.session and then serializes the result using JSON. This method
336 336 should be used by self._on_zmq_reply to build messages that can
337 337 be sent back to the browser.
338 338 """
339 339 idents, msg_list = self.session.feed_identities(msg_list)
340 340 msg = self.session.unserialize(msg_list)
341 341 try:
342 342 msg['header'].pop('date')
343 343 except KeyError:
344 344 pass
345 345 try:
346 346 msg['parent_header'].pop('date')
347 347 except KeyError:
348 348 pass
349 349 msg.pop('buffers')
350 350 return jsonapi.dumps(msg)
351 351
352 352 def _on_zmq_reply(self, msg_list):
353 353 try:
354 354 msg = self._reserialize_reply(msg_list)
355 355 except:
356 356 self.application.log.critical("Malformed message: %r" % msg_list)
357 357 else:
358 358 self.write_message(msg)
359 359
360 def allow_draft76(self):
361 """Allow draft 76, until browsers such as Safari update to RFC 6455.
362
363 This has been disabled by default in tornado in release 2.2.0, and
364 support will be removed in later versions.
365 """
366 return True
367
360 368
361 369 class AuthenticatedZMQStreamHandler(ZMQStreamHandler):
362 370
363 371 def open(self, kernel_id):
364 372 self.kernel_id = kernel_id.decode('ascii')
365 373 try:
366 374 cfg = self.application.ipython_app.config
367 375 except AttributeError:
368 376 # protect from the case where this is run from something other than
369 377 # the notebook app:
370 378 cfg = None
371 379 self.session = Session(config=cfg)
372 380 self.save_on_message = self.on_message
373 381 self.on_message = self.on_first_message
374 382
375 383 def get_current_user(self):
376 384 user_id = self.get_secure_cookie("username")
377 385 if user_id == '' or (user_id is None and not self.application.password):
378 386 user_id = 'anonymous'
379 387 return user_id
380 388
381 389 def _inject_cookie_message(self, msg):
382 390 """Inject the first message, which is the document cookie,
383 391 for authentication."""
384 392 if isinstance(msg, unicode):
385 393 # Cookie can't constructor doesn't accept unicode strings for some reason
386 394 msg = msg.encode('utf8', 'replace')
387 395 try:
388 396 self.request._cookies = Cookie.SimpleCookie(msg)
389 397 except:
390 398 logging.warn("couldn't parse cookie string: %s",msg, exc_info=True)
391 399
392 400 def on_first_message(self, msg):
393 401 self._inject_cookie_message(msg)
394 402 if self.get_current_user() is None:
395 403 logging.warn("Couldn't authenticate WebSocket connection")
396 404 raise web.HTTPError(403)
397 405 self.on_message = self.save_on_message
398 406
399 407
400 408 class IOPubHandler(AuthenticatedZMQStreamHandler):
401 409
402 410 def initialize(self, *args, **kwargs):
403 411 self._kernel_alive = True
404 412 self._beating = False
405 413 self.iopub_stream = None
406 414 self.hb_stream = None
407 415
408 416 def on_first_message(self, msg):
409 417 try:
410 418 super(IOPubHandler, self).on_first_message(msg)
411 419 except web.HTTPError:
412 420 self.close()
413 421 return
414 422 km = self.application.kernel_manager
415 423 self.time_to_dead = km.time_to_dead
416 424 self.first_beat = km.first_beat
417 425 kernel_id = self.kernel_id
418 426 try:
419 427 self.iopub_stream = km.create_iopub_stream(kernel_id)
420 428 self.hb_stream = km.create_hb_stream(kernel_id)
421 429 except web.HTTPError:
422 430 # WebSockets don't response to traditional error codes so we
423 431 # close the connection.
424 432 if not self.stream.closed():
425 433 self.stream.close()
426 434 self.close()
427 435 else:
428 436 self.iopub_stream.on_recv(self._on_zmq_reply)
429 437 self.start_hb(self.kernel_died)
430 438
431 439 def on_message(self, msg):
432 440 pass
433 441
434 442 def on_close(self):
435 443 # This method can be called twice, once by self.kernel_died and once
436 444 # from the WebSocket close event. If the WebSocket connection is
437 445 # closed before the ZMQ streams are setup, they could be None.
438 446 self.stop_hb()
439 447 if self.iopub_stream is not None and not self.iopub_stream.closed():
440 448 self.iopub_stream.on_recv(None)
441 449 self.iopub_stream.close()
442 450 if self.hb_stream is not None and not self.hb_stream.closed():
443 451 self.hb_stream.close()
444 452
445 453 def start_hb(self, callback):
446 454 """Start the heartbeating and call the callback if the kernel dies."""
447 455 if not self._beating:
448 456 self._kernel_alive = True
449 457
450 458 def ping_or_dead():
451 459 self.hb_stream.flush()
452 460 if self._kernel_alive:
453 461 self._kernel_alive = False
454 462 self.hb_stream.send(b'ping')
455 463 # flush stream to force immediate socket send
456 464 self.hb_stream.flush()
457 465 else:
458 466 try:
459 467 callback()
460 468 except:
461 469 pass
462 470 finally:
463 471 self.stop_hb()
464 472
465 473 def beat_received(msg):
466 474 self._kernel_alive = True
467 475
468 476 self.hb_stream.on_recv(beat_received)
469 477 loop = ioloop.IOLoop.instance()
470 478 self._hb_periodic_callback = ioloop.PeriodicCallback(ping_or_dead, self.time_to_dead*1000, loop)
471 479 loop.add_timeout(time.time()+self.first_beat, self._really_start_hb)
472 480 self._beating= True
473 481
474 482 def _really_start_hb(self):
475 483 """callback for delayed heartbeat start
476 484
477 485 Only start the hb loop if we haven't been closed during the wait.
478 486 """
479 487 if self._beating and not self.hb_stream.closed():
480 488 self._hb_periodic_callback.start()
481 489
482 490 def stop_hb(self):
483 491 """Stop the heartbeating and cancel all related callbacks."""
484 492 if self._beating:
485 493 self._beating = False
486 494 self._hb_periodic_callback.stop()
487 495 if not self.hb_stream.closed():
488 496 self.hb_stream.on_recv(None)
489 497
490 498 def kernel_died(self):
491 499 self.application.kernel_manager.delete_mapping_for_kernel(self.kernel_id)
492 500 self.application.log.error("Kernel %s failed to respond to heartbeat", self.kernel_id)
493 501 self.write_message(
494 502 {'header': {'msg_type': 'status'},
495 503 'parent_header': {},
496 504 'content': {'execution_state':'dead'}
497 505 }
498 506 )
499 507 self.on_close()
500 508
501 509
502 510 class ShellHandler(AuthenticatedZMQStreamHandler):
503 511
504 512 def initialize(self, *args, **kwargs):
505 513 self.shell_stream = None
506 514
507 515 def on_first_message(self, msg):
508 516 try:
509 517 super(ShellHandler, self).on_first_message(msg)
510 518 except web.HTTPError:
511 519 self.close()
512 520 return
513 521 km = self.application.kernel_manager
514 522 self.max_msg_size = km.max_msg_size
515 523 kernel_id = self.kernel_id
516 524 try:
517 525 self.shell_stream = km.create_shell_stream(kernel_id)
518 526 except web.HTTPError:
519 527 # WebSockets don't response to traditional error codes so we
520 528 # close the connection.
521 529 if not self.stream.closed():
522 530 self.stream.close()
523 531 self.close()
524 532 else:
525 533 self.shell_stream.on_recv(self._on_zmq_reply)
526 534
527 535 def on_message(self, msg):
528 536 if len(msg) < self.max_msg_size:
529 537 msg = jsonapi.loads(msg)
530 538 self.session.send(self.shell_stream, msg)
531 539
532 540 def on_close(self):
533 541 # Make sure the stream exists and is not already closed.
534 542 if self.shell_stream is not None and not self.shell_stream.closed():
535 543 self.shell_stream.close()
536 544
537 545
538 546 #-----------------------------------------------------------------------------
539 547 # Notebook web service handlers
540 548 #-----------------------------------------------------------------------------
541 549
542 550 class NotebookRootHandler(AuthenticatedHandler):
543 551
544 552 @authenticate_unless_readonly
545 553 def get(self):
546 554
547 555 nbm = self.application.notebook_manager
548 556 files = nbm.list_notebooks()
549 557 self.finish(jsonapi.dumps(files))
550 558
551 559 @web.authenticated
552 560 def post(self):
553 561 nbm = self.application.notebook_manager
554 562 body = self.request.body.strip()
555 563 format = self.get_argument('format', default='json')
556 564 name = self.get_argument('name', default=None)
557 565 if body:
558 566 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
559 567 else:
560 568 notebook_id = nbm.new_notebook()
561 569 self.set_header('Location', '/'+notebook_id)
562 570 self.finish(jsonapi.dumps(notebook_id))
563 571
564 572
565 573 class NotebookHandler(AuthenticatedHandler):
566 574
567 575 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
568 576
569 577 @authenticate_unless_readonly
570 578 def get(self, notebook_id):
571 579 nbm = self.application.notebook_manager
572 580 format = self.get_argument('format', default='json')
573 581 last_mod, name, data = nbm.get_notebook(notebook_id, format)
574 582
575 583 if format == u'json':
576 584 self.set_header('Content-Type', 'application/json')
577 585 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
578 586 elif format == u'py':
579 587 self.set_header('Content-Type', 'application/x-python')
580 588 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
581 589 self.set_header('Last-Modified', last_mod)
582 590 self.finish(data)
583 591
584 592 @web.authenticated
585 593 def put(self, notebook_id):
586 594 nbm = self.application.notebook_manager
587 595 format = self.get_argument('format', default='json')
588 596 name = self.get_argument('name', default=None)
589 597 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
590 598 self.set_status(204)
591 599 self.finish()
592 600
593 601 @web.authenticated
594 602 def delete(self, notebook_id):
595 603 nbm = self.application.notebook_manager
596 604 nbm.delete_notebook(notebook_id)
597 605 self.set_status(204)
598 606 self.finish()
599 607
600 608 #-----------------------------------------------------------------------------
601 609 # RST web service handlers
602 610 #-----------------------------------------------------------------------------
603 611
604 612
605 613 class RSTHandler(AuthenticatedHandler):
606 614
607 615 @web.authenticated
608 616 def post(self):
609 617 if publish_string is None:
610 618 raise web.HTTPError(503, u'docutils not available')
611 619 body = self.request.body.strip()
612 620 source = body
613 621 # template_path=os.path.join(os.path.dirname(__file__), u'templates', u'rst_template.html')
614 622 defaults = {'file_insertion_enabled': 0,
615 623 'raw_enabled': 0,
616 624 '_disable_config': 1,
617 625 'stylesheet_path': 0
618 626 # 'template': template_path
619 627 }
620 628 try:
621 629 html = publish_string(source, writer_name='html',
622 630 settings_overrides=defaults
623 631 )
624 632 except:
625 633 raise web.HTTPError(400, u'Invalid RST')
626 634 print html
627 635 self.set_header('Content-Type', 'text/html')
628 636 self.finish(html)
629 637
630 638
General Comments 0
You need to be logged in to leave comments. Login now