##// END OF EJS Templates
fix Location headers
MinRK -
Show More
@@ -1,928 +1,931 b''
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 Cookie
20 20 import datetime
21 21 import email.utils
22 22 import hashlib
23 23 import logging
24 24 import mimetypes
25 25 import os
26 26 import stat
27 27 import threading
28 28 import time
29 29 import uuid
30 30
31 31 from tornado.escape import url_escape
32 32 from tornado import web
33 33 from tornado import websocket
34 34
35 35 try:
36 36 from tornado.log import app_log
37 37 except ImportError:
38 38 app_log = logging.getLogger()
39 39
40 40 from zmq.eventloop import ioloop
41 41 from zmq.utils import jsonapi
42 42
43 43 from IPython.config import Application
44 44 from IPython.external.decorator import decorator
45 45 from IPython.kernel.zmq.session import Session
46 46 from IPython.lib.security import passwd_check
47 47 from IPython.utils.jsonutil import date_default
48 48 from IPython.utils.path import filefind
49 49 from IPython.utils.py3compat import PY3
50 50
51 51 try:
52 52 from docutils.core import publish_string
53 53 except ImportError:
54 54 publish_string = None
55 55
56 56 #-----------------------------------------------------------------------------
57 57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 58 #-----------------------------------------------------------------------------
59 59
60 60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 61 # parts tornado cares about haven't really changed, so it's OK to continue
62 62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 63 # version as of Oct 30/2011) the version check fails, see the issue report:
64 64
65 65 # https://github.com/facebook/tornado/issues/385
66 66
67 67 # This issue has been fixed in Tornado post 2.1.1:
68 68
69 69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70 70
71 71 # Here we manually apply the same patch as above so that users of IPython can
72 72 # continue to work with an officially released Tornado. We make the
73 73 # monkeypatch version check as narrow as possible to limit its effects; once
74 74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75 75
76 76 import tornado
77 77
78 78 if tornado.version_info <= (2,1,1):
79 79
80 80 def _execute(self, transforms, *args, **kwargs):
81 81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82 82
83 83 self.open_args = args
84 84 self.open_kwargs = kwargs
85 85
86 86 # The difference between version 8 and 13 is that in 8 the
87 87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 88 # simply "Origin".
89 89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 90 self.ws_connection = WebSocketProtocol8(self)
91 91 self.ws_connection.accept_connection()
92 92
93 93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 94 self.stream.write(tornado.escape.utf8(
95 95 "HTTP/1.1 426 Upgrade Required\r\n"
96 96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 97 self.stream.close()
98 98
99 99 else:
100 100 self.ws_connection = WebSocketProtocol76(self)
101 101 self.ws_connection.accept_connection()
102 102
103 103 websocket.WebSocketHandler._execute = _execute
104 104 del _execute
105 105
106 106 #-----------------------------------------------------------------------------
107 107 # Decorator for disabling read-only handlers
108 108 #-----------------------------------------------------------------------------
109 109
110 110 @decorator
111 111 def not_if_readonly(f, self, *args, **kwargs):
112 112 if self.settings.get('read_only', False):
113 113 raise web.HTTPError(403, "Notebook server is read-only")
114 114 else:
115 115 return f(self, *args, **kwargs)
116 116
117 117 @decorator
118 118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 119 """authenticate this page *unless* readonly view is active.
120 120
121 121 In read-only mode, the notebook list and print view should
122 122 be accessible without authentication.
123 123 """
124 124
125 125 @web.authenticated
126 126 def auth_f(self, *args, **kwargs):
127 127 return f(self, *args, **kwargs)
128 128
129 129 if self.settings.get('read_only', False):
130 130 return f(self, *args, **kwargs)
131 131 else:
132 132 return auth_f(self, *args, **kwargs)
133 133
134 134 def urljoin(*pieces):
135 135 """Join components of url into a relative url
136 136
137 137 Use to prevent double slash when joining subpath
138 138 """
139 139 striped = [s.strip('/') for s in pieces]
140 140 return '/'.join(s for s in striped if s)
141 141
142 142 #-----------------------------------------------------------------------------
143 143 # Top-level handlers
144 144 #-----------------------------------------------------------------------------
145 145
146 146 class RequestHandler(web.RequestHandler):
147 147 """RequestHandler with default variable setting."""
148 148
149 149 def render(*args, **kwargs):
150 150 kwargs.setdefault('message', '')
151 151 return web.RequestHandler.render(*args, **kwargs)
152 152
153 153 class AuthenticatedHandler(RequestHandler):
154 154 """A RequestHandler with an authenticated user."""
155 155
156 156 def clear_login_cookie(self):
157 157 self.clear_cookie(self.cookie_name)
158 158
159 159 def get_current_user(self):
160 160 user_id = self.get_secure_cookie(self.cookie_name)
161 161 # For now the user_id should not return empty, but it could eventually
162 162 if user_id == '':
163 163 user_id = 'anonymous'
164 164 if user_id is None:
165 165 # prevent extra Invalid cookie sig warnings:
166 166 self.clear_login_cookie()
167 167 if not self.read_only and not self.login_available:
168 168 user_id = 'anonymous'
169 169 return user_id
170 170
171 171 @property
172 172 def cookie_name(self):
173 173 return self.settings.get('cookie_name', '')
174 174
175 175 @property
176 176 def password(self):
177 177 """our password"""
178 178 return self.settings.get('password', '')
179 179
180 180 @property
181 181 def logged_in(self):
182 182 """Is a user currently logged in?
183 183
184 184 """
185 185 user = self.get_current_user()
186 186 return (user and not user == 'anonymous')
187 187
188 188 @property
189 189 def login_available(self):
190 190 """May a user proceed to log in?
191 191
192 192 This returns True if login capability is available, irrespective of
193 193 whether the user is already logged in or not.
194 194
195 195 """
196 196 return bool(self.settings.get('password', ''))
197 197
198 198 @property
199 199 def read_only(self):
200 200 """Is the notebook read-only?
201 201
202 202 """
203 203 return self.settings.get('read_only', False)
204 204
205 205
206 206 class IPythonHandler(AuthenticatedHandler):
207 207 """IPython-specific extensions to authenticated handling
208 208
209 209 Mostly property shortcuts to IPython-specific settings.
210 210 """
211 211
212 212 @property
213 213 def config(self):
214 214 return self.settings.get('config', None)
215 215
216 216 @property
217 217 def log(self):
218 218 """use the IPython log by default, falling back on tornado's logger"""
219 219 if Application.initialized():
220 220 return Application.instance().log
221 221 else:
222 222 return app_log
223 223
224 224 @property
225 225 def use_less(self):
226 226 """Use less instead of css in templates"""
227 227 return self.settings.get('use_less', False)
228 228
229 229 #---------------------------------------------------------------
230 230 # URLs
231 231 #---------------------------------------------------------------
232 232
233 233 @property
234 234 def ws_url(self):
235 235 """websocket url matching the current request
236 236
237 237 turns http[s]://host[:port] into
238 238 ws[s]://host[:port]
239 239 """
240 240 proto = self.request.protocol.replace('http', 'ws')
241 241 host = self.settings.get('websocket_host', '')
242 242 # default to config value
243 243 if host == '':
244 244 host = self.request.host # get from request
245 245 return "%s://%s" % (proto, host)
246 246
247 247 @property
248 248 def mathjax_url(self):
249 249 return self.settings.get('mathjax_url', '')
250 250
251 251 @property
252 252 def base_project_url(self):
253 253 return self.settings.get('base_project_url', '/')
254 254
255 255 @property
256 256 def base_kernel_url(self):
257 257 return self.settings.get('base_kernel_url', '/')
258 258
259 259 #---------------------------------------------------------------
260 260 # Manager objects
261 261 #---------------------------------------------------------------
262 262
263 263 @property
264 264 def kernel_manager(self):
265 265 return self.settings['kernel_manager']
266 266
267 267 @property
268 268 def notebook_manager(self):
269 269 return self.settings['notebook_manager']
270 270
271 271 @property
272 272 def cluster_manager(self):
273 273 return self.settings['cluster_manager']
274 274
275 275 @property
276 276 def project(self):
277 277 return self.notebook_manager.notebook_dir
278 278
279 279 #---------------------------------------------------------------
280 280 # template rendering
281 281 #---------------------------------------------------------------
282 282
283 283 def get_template(self, name):
284 284 """Return the jinja template object for a given name"""
285 285 return self.settings['jinja2_env'].get_template(name)
286 286
287 287 def render_template(self, name, **ns):
288 288 ns.update(self.template_namespace)
289 289 template = self.get_template(name)
290 290 return template.render(**ns)
291 291
292 292 @property
293 293 def template_namespace(self):
294 294 return dict(
295 295 base_project_url=self.base_project_url,
296 296 base_kernel_url=self.base_kernel_url,
297 297 read_only=self.read_only,
298 298 logged_in=self.logged_in,
299 299 login_available=self.login_available,
300 300 use_less=self.use_less,
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 @authenticate_unless_readonly
307 307 def get(self, path):
308 308 return web.StaticFileHandler.get(self, path)
309 309
310 310
311 311 class ProjectDashboardHandler(IPythonHandler):
312 312
313 313 @authenticate_unless_readonly
314 314 def get(self):
315 315 self.write(self.render_template('projectdashboard.html',
316 316 project=self.project,
317 317 project_component=self.project.split('/'),
318 318 ))
319 319
320 320
321 321 class LoginHandler(IPythonHandler):
322 322
323 323 def _render(self, message=None):
324 324 self.write(self.render_template('login.html',
325 325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 326 message=message,
327 327 ))
328 328
329 329 def get(self):
330 330 if self.current_user:
331 331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 332 else:
333 333 self._render()
334 334
335 335 def post(self):
336 336 pwd = self.get_argument('password', default=u'')
337 337 if self.login_available:
338 338 if passwd_check(self.password, pwd):
339 339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 340 else:
341 341 self._render(message={'error': 'Invalid password'})
342 342 return
343 343
344 344 self.redirect(self.get_argument('next', default=self.base_project_url))
345 345
346 346
347 347 class LogoutHandler(IPythonHandler):
348 348
349 349 def get(self):
350 350 self.clear_login_cookie()
351 351 if self.login_available:
352 352 message = {'info': 'Successfully logged out.'}
353 353 else:
354 354 message = {'warning': 'Cannot log out. Notebook authentication '
355 355 'is disabled.'}
356 356 self.write(self.render_template('logout.html',
357 357 message=message))
358 358
359 359
360 360 class NewHandler(IPythonHandler):
361 361
362 362 @web.authenticated
363 363 def get(self):
364 364 notebook_id = self.notebook_manager.new_notebook()
365 365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366 366
367 367 class NamedNotebookHandler(IPythonHandler):
368 368
369 369 @authenticate_unless_readonly
370 370 def get(self, notebook_id):
371 371 nbm = self.notebook_manager
372 372 if not nbm.notebook_exists(notebook_id):
373 373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 374 self.write(self.render_template('notebook.html',
375 375 project=self.project,
376 376 notebook_id=notebook_id,
377 377 kill_kernel=False,
378 378 mathjax_url=self.mathjax_url,
379 379 )
380 380 )
381 381
382 382
383 383 #-----------------------------------------------------------------------------
384 384 # Kernel handlers
385 385 #-----------------------------------------------------------------------------
386 386
387 387
388 388 class MainKernelHandler(IPythonHandler):
389 389
390 390 @web.authenticated
391 391 def get(self):
392 392 km = self.kernel_manager
393 393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394 394
395 395 @web.authenticated
396 396 def post(self):
397 397 km = self.kernel_manager
398 398 nbm = self.notebook_manager
399 399 notebook_id = self.get_argument('notebook', default=None)
400 400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '/'+kernel_id)
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 403 self.finish(jsonapi.dumps(data))
404 404
405 405
406 406 class KernelHandler(IPythonHandler):
407 407
408 408 SUPPORTED_METHODS = ('DELETE')
409 409
410 410 @web.authenticated
411 411 def delete(self, kernel_id):
412 412 km = self.kernel_manager
413 413 km.shutdown_kernel(kernel_id)
414 414 self.set_status(204)
415 415 self.finish()
416 416
417 417
418 418 class KernelActionHandler(IPythonHandler):
419 419
420 420 @web.authenticated
421 421 def post(self, kernel_id, action):
422 422 km = self.kernel_manager
423 423 if action == 'interrupt':
424 424 km.interrupt_kernel(kernel_id)
425 425 self.set_status(204)
426 426 if action == 'restart':
427 427 km.restart_kernel(kernel_id)
428 428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '/'+kernel_id)
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 430 self.write(jsonapi.dumps(data))
431 431 self.finish()
432 432
433 433
434 434 class ZMQStreamHandler(websocket.WebSocketHandler):
435 435
436 436 def clear_cookie(self, *args, **kwargs):
437 437 """meaningless for websockets"""
438 438 pass
439 439
440 440 def _reserialize_reply(self, msg_list):
441 441 """Reserialize a reply message using JSON.
442 442
443 443 This takes the msg list from the ZMQ socket, unserializes it using
444 444 self.session and then serializes the result using JSON. This method
445 445 should be used by self._on_zmq_reply to build messages that can
446 446 be sent back to the browser.
447 447 """
448 448 idents, msg_list = self.session.feed_identities(msg_list)
449 449 msg = self.session.unserialize(msg_list)
450 450 try:
451 451 msg['header'].pop('date')
452 452 except KeyError:
453 453 pass
454 454 try:
455 455 msg['parent_header'].pop('date')
456 456 except KeyError:
457 457 pass
458 458 msg.pop('buffers')
459 459 return jsonapi.dumps(msg, default=date_default)
460 460
461 461 def _on_zmq_reply(self, msg_list):
462 462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 463 # eventloop but hasn't been called.
464 464 if self.stream.closed(): return
465 465 try:
466 466 msg = self._reserialize_reply(msg_list)
467 467 except Exception:
468 468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 469 else:
470 470 self.write_message(msg)
471 471
472 472 def allow_draft76(self):
473 473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474 474
475 475 This has been disabled by default in tornado in release 2.2.0, and
476 476 support will be removed in later versions.
477 477 """
478 478 return True
479 479
480 480
481 481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482 482
483 483 def open(self, kernel_id):
484 484 self.kernel_id = kernel_id.decode('ascii')
485 485 self.session = Session(config=self.config)
486 486 self.save_on_message = self.on_message
487 487 self.on_message = self.on_first_message
488 488
489 489 def _inject_cookie_message(self, msg):
490 490 """Inject the first message, which is the document cookie,
491 491 for authentication."""
492 492 if not PY3 and isinstance(msg, unicode):
493 493 # Cookie constructor doesn't accept unicode strings
494 494 # under Python 2.x for some reason
495 495 msg = msg.encode('utf8', 'replace')
496 496 try:
497 497 identity, msg = msg.split(':', 1)
498 498 self.session.session = identity.decode('ascii')
499 499 except Exception:
500 500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501 501
502 502 try:
503 503 self.request._cookies = Cookie.SimpleCookie(msg)
504 504 except:
505 505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506 506
507 507 def on_first_message(self, msg):
508 508 self._inject_cookie_message(msg)
509 509 if self.get_current_user() is None:
510 510 self.log.warn("Couldn't authenticate WebSocket connection")
511 511 raise web.HTTPError(403)
512 512 self.on_message = self.save_on_message
513 513
514 514
515 515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516 516
517 517 @property
518 518 def max_msg_size(self):
519 519 return self.settings.get('max_msg_size', 65535)
520 520
521 521 def create_stream(self):
522 522 km = self.kernel_manager
523 523 meth = getattr(km, 'connect_%s' % self.channel)
524 524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525 525
526 526 def initialize(self, *args, **kwargs):
527 527 self.zmq_stream = None
528 528
529 529 def on_first_message(self, msg):
530 530 try:
531 531 super(ZMQChannelHandler, self).on_first_message(msg)
532 532 except web.HTTPError:
533 533 self.close()
534 534 return
535 535 try:
536 536 self.create_stream()
537 537 except web.HTTPError:
538 538 # WebSockets don't response to traditional error codes so we
539 539 # close the connection.
540 540 if not self.stream.closed():
541 541 self.stream.close()
542 542 self.close()
543 543 else:
544 544 self.zmq_stream.on_recv(self._on_zmq_reply)
545 545
546 546 def on_message(self, msg):
547 547 if len(msg) < self.max_msg_size:
548 548 msg = jsonapi.loads(msg)
549 549 self.session.send(self.zmq_stream, msg)
550 550
551 551 def on_close(self):
552 552 # This method can be called twice, once by self.kernel_died and once
553 553 # from the WebSocket close event. If the WebSocket connection is
554 554 # closed before the ZMQ streams are setup, they could be None.
555 555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 556 self.zmq_stream.on_recv(None)
557 557 self.zmq_stream.close()
558 558
559 559
560 560 class IOPubHandler(ZMQChannelHandler):
561 561 channel = 'iopub'
562 562
563 563 def create_stream(self):
564 564 super(IOPubHandler, self).create_stream()
565 565 km = self.kernel_manager
566 566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568 568
569 569 def on_close(self):
570 570 km = self.kernel_manager
571 571 if self.kernel_id in km:
572 572 km.remove_restart_callback(
573 573 self.kernel_id, self.on_kernel_restarted,
574 574 )
575 575 km.remove_restart_callback(
576 576 self.kernel_id, self.on_restart_failed, 'dead',
577 577 )
578 578 super(IOPubHandler, self).on_close()
579 579
580 580 def _send_status_message(self, status):
581 581 msg = self.session.msg("status",
582 582 {'execution_state': status}
583 583 )
584 584 self.write_message(jsonapi.dumps(msg, default=date_default))
585 585
586 586 def on_kernel_restarted(self):
587 587 logging.warn("kernel %s restarted", self.kernel_id)
588 588 self._send_status_message('restarting')
589 589
590 590 def on_restart_failed(self):
591 591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 592 self._send_status_message('dead')
593 593
594 594 def on_message(self, msg):
595 595 """IOPub messages make no sense"""
596 596 pass
597 597
598 598 class ShellHandler(ZMQChannelHandler):
599 599 channel = 'shell'
600 600
601 601 class StdinHandler(ZMQChannelHandler):
602 602 channel = 'stdin'
603 603
604 604
605 605 #-----------------------------------------------------------------------------
606 606 # Notebook web service handlers
607 607 #-----------------------------------------------------------------------------
608 608
609 609 class NotebookRedirectHandler(IPythonHandler):
610 610
611 611 @authenticate_unless_readonly
612 612 def get(self, notebook_name):
613 613 # strip trailing .ipynb:
614 614 notebook_name = os.path.splitext(notebook_name)[0]
615 615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 616 if notebook_id:
617 617 url = self.settings.get('base_project_url', '/') + notebook_id
618 618 return self.redirect(url)
619 619 else:
620 620 raise HTTPError(404)
621 621
622 622
623 623 class NotebookRootHandler(IPythonHandler):
624 624
625 625 @authenticate_unless_readonly
626 626 def get(self):
627 627 nbm = self.notebook_manager
628 628 km = self.kernel_manager
629 629 files = nbm.list_notebooks()
630 630 for f in files :
631 631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 632 self.finish(jsonapi.dumps(files))
633 633
634 634 @web.authenticated
635 635 def post(self):
636 636 nbm = self.notebook_manager
637 637 body = self.request.body.strip()
638 638 format = self.get_argument('format', default='json')
639 639 name = self.get_argument('name', default=None)
640 640 if body:
641 641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 642 else:
643 643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '/'+notebook_id)
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 645 self.finish(jsonapi.dumps(notebook_id))
646 646
647 647
648 648 class NotebookHandler(IPythonHandler):
649 649
650 650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651 651
652 652 @authenticate_unless_readonly
653 653 def get(self, notebook_id):
654 654 nbm = self.notebook_manager
655 655 format = self.get_argument('format', default='json')
656 656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657 657
658 658 if format == u'json':
659 659 self.set_header('Content-Type', 'application/json')
660 660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 661 elif format == u'py':
662 662 self.set_header('Content-Type', 'application/x-python')
663 663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 664 self.set_header('Last-Modified', last_mod)
665 665 self.finish(data)
666 666
667 667 @web.authenticated
668 668 def put(self, notebook_id):
669 669 nbm = self.notebook_manager
670 670 format = self.get_argument('format', default='json')
671 671 name = self.get_argument('name', default=None)
672 672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 673 self.set_status(204)
674 674 self.finish()
675 675
676 676 @web.authenticated
677 677 def delete(self, notebook_id):
678 678 self.notebook_manager.delete_notebook(notebook_id)
679 679 self.set_status(204)
680 680 self.finish()
681 681
682 682
683 683 class NotebookCheckpointsHandler(IPythonHandler):
684 684
685 685 SUPPORTED_METHODS = ('GET', 'POST')
686 686
687 687 @web.authenticated
688 688 def get(self, notebook_id):
689 689 """get lists checkpoints for a notebook"""
690 690 nbm = self.notebook_manager
691 691 checkpoints = nbm.list_checkpoints(notebook_id)
692 692 data = jsonapi.dumps(checkpoints, default=date_default)
693 693 self.finish(data)
694 694
695 695 @web.authenticated
696 696 def post(self, notebook_id):
697 697 """post creates a new checkpoint"""
698 698 nbm = self.notebook_manager
699 699 checkpoint = nbm.create_checkpoint(notebook_id)
700 700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
701 704
702 705 self.finish(data)
703 706
704 707
705 708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
706 709
707 710 SUPPORTED_METHODS = ('POST', 'DELETE')
708 711
709 712 @web.authenticated
710 713 def post(self, notebook_id, checkpoint_id):
711 714 """post restores a notebook from a checkpoint"""
712 715 nbm = self.notebook_manager
713 716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
714 717 self.set_status(204)
715 718 self.finish()
716 719
717 720 @web.authenticated
718 721 def delete(self, notebook_id, checkpoint_id):
719 722 """delete clears a checkpoint for a given notebook"""
720 723 nbm = self.notebook_manager
721 724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
722 725 self.set_status(204)
723 726 self.finish()
724 727
725 728
726 729 class NotebookCopyHandler(IPythonHandler):
727 730
728 731 @web.authenticated
729 732 def get(self, notebook_id):
730 733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
731 734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
732 735
733 736
734 737 #-----------------------------------------------------------------------------
735 738 # Cluster handlers
736 739 #-----------------------------------------------------------------------------
737 740
738 741
739 742 class MainClusterHandler(IPythonHandler):
740 743
741 744 @web.authenticated
742 745 def get(self):
743 746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
744 747
745 748
746 749 class ClusterProfileHandler(IPythonHandler):
747 750
748 751 @web.authenticated
749 752 def get(self, profile):
750 753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
751 754
752 755
753 756 class ClusterActionHandler(IPythonHandler):
754 757
755 758 @web.authenticated
756 759 def post(self, profile, action):
757 760 cm = self.cluster_manager
758 761 if action == 'start':
759 762 n = self.get_argument('n',default=None)
760 763 if n is None:
761 764 data = cm.start_cluster(profile)
762 765 else:
763 766 data = cm.start_cluster(profile, int(n))
764 767 if action == 'stop':
765 768 data = cm.stop_cluster(profile)
766 769 self.finish(jsonapi.dumps(data))
767 770
768 771
769 772 #-----------------------------------------------------------------------------
770 773 # File handler
771 774 #-----------------------------------------------------------------------------
772 775
773 776 # to minimize subclass changes:
774 777 HTTPError = web.HTTPError
775 778
776 779 class FileFindHandler(web.StaticFileHandler):
777 780 """subclass of StaticFileHandler for serving files from a search path"""
778 781
779 782 _static_paths = {}
780 783 # _lock is needed for tornado < 2.2.0 compat
781 784 _lock = threading.Lock() # protects _static_hashes
782 785
783 786 def initialize(self, path, default_filename=None):
784 787 if isinstance(path, basestring):
785 788 path = [path]
786 789 self.roots = tuple(
787 790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
788 791 )
789 792 self.default_filename = default_filename
790 793
791 794 @classmethod
792 795 def locate_file(cls, path, roots):
793 796 """locate a file to serve on our static file search path"""
794 797 with cls._lock:
795 798 if path in cls._static_paths:
796 799 return cls._static_paths[path]
797 800 try:
798 801 abspath = os.path.abspath(filefind(path, roots))
799 802 except IOError:
800 803 # empty string should always give exists=False
801 804 return ''
802 805
803 806 # os.path.abspath strips a trailing /
804 807 # it needs to be temporarily added back for requests to root/
805 808 if not (abspath + os.path.sep).startswith(roots):
806 809 raise HTTPError(403, "%s is not in root static directory", path)
807 810
808 811 cls._static_paths[path] = abspath
809 812 return abspath
810 813
811 814 def get(self, path, include_body=True):
812 815 path = self.parse_url_path(path)
813 816
814 817 # begin subclass override
815 818 abspath = self.locate_file(path, self.roots)
816 819 # end subclass override
817 820
818 821 if os.path.isdir(abspath) and self.default_filename is not None:
819 822 # need to look at the request.path here for when path is empty
820 823 # but there is some prefix to the path that was already
821 824 # trimmed by the routing
822 825 if not self.request.path.endswith("/"):
823 826 self.redirect(self.request.path + "/")
824 827 return
825 828 abspath = os.path.join(abspath, self.default_filename)
826 829 if not os.path.exists(abspath):
827 830 raise HTTPError(404)
828 831 if not os.path.isfile(abspath):
829 832 raise HTTPError(403, "%s is not a file", path)
830 833
831 834 stat_result = os.stat(abspath)
832 835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
833 836
834 837 self.set_header("Last-Modified", modified)
835 838
836 839 mime_type, encoding = mimetypes.guess_type(abspath)
837 840 if mime_type:
838 841 self.set_header("Content-Type", mime_type)
839 842
840 843 cache_time = self.get_cache_time(path, modified, mime_type)
841 844
842 845 if cache_time > 0:
843 846 self.set_header("Expires", datetime.datetime.utcnow() + \
844 847 datetime.timedelta(seconds=cache_time))
845 848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
846 849 else:
847 850 self.set_header("Cache-Control", "public")
848 851
849 852 self.set_extra_headers(path)
850 853
851 854 # Check the If-Modified-Since, and don't send the result if the
852 855 # content has not been modified
853 856 ims_value = self.request.headers.get("If-Modified-Since")
854 857 if ims_value is not None:
855 858 date_tuple = email.utils.parsedate(ims_value)
856 859 if_since = datetime.datetime(*date_tuple[:6])
857 860 if if_since >= modified:
858 861 self.set_status(304)
859 862 return
860 863
861 864 with open(abspath, "rb") as file:
862 865 data = file.read()
863 866 hasher = hashlib.sha1()
864 867 hasher.update(data)
865 868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
866 869 if include_body:
867 870 self.write(data)
868 871 else:
869 872 assert self.request.method == "HEAD"
870 873 self.set_header("Content-Length", len(data))
871 874
872 875 @classmethod
873 876 def get_version(cls, settings, path):
874 877 """Generate the version string to be used in static URLs.
875 878
876 879 This method may be overridden in subclasses (but note that it
877 880 is a class method rather than a static method). The default
878 881 implementation uses a hash of the file's contents.
879 882
880 883 ``settings`` is the `Application.settings` dictionary and ``path``
881 884 is the relative location of the requested asset on the filesystem.
882 885 The returned value should be a string, or ``None`` if no version
883 886 could be determined.
884 887 """
885 888 # begin subclass override:
886 889 static_paths = settings['static_path']
887 890 if isinstance(static_paths, basestring):
888 891 static_paths = [static_paths]
889 892 roots = tuple(
890 893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
891 894 )
892 895
893 896 try:
894 897 abs_path = filefind(path, roots)
895 898 except IOError:
896 899 app_log.error("Could not find static file %r", path)
897 900 return None
898 901
899 902 # end subclass override
900 903
901 904 with cls._lock:
902 905 hashes = cls._static_hashes
903 906 if abs_path not in hashes:
904 907 try:
905 908 f = open(abs_path, "rb")
906 909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
907 910 f.close()
908 911 except Exception:
909 912 app_log.error("Could not open static file %r", path)
910 913 hashes[abs_path] = None
911 914 hsh = hashes.get(abs_path)
912 915 if hsh:
913 916 return hsh[:5]
914 917 return None
915 918
916 919
917 920 def parse_url_path(self, url_path):
918 921 """Converts a static URL path into a filesystem path.
919 922
920 923 ``url_path`` is the path component of the URL with
921 924 ``static_url_prefix`` removed. The return value should be
922 925 filesystem path relative to ``static_path``.
923 926 """
924 927 if os.path.sep != "/":
925 928 url_path = url_path.replace("/", os.path.sep)
926 929 return url_path
927 930
928 931
General Comments 0
You need to be logged in to leave comments. Login now