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