##// END OF EJS Templates
make content_security_policy a property...
Min RK -
Show More
@@ -1,537 +1,553 b''
1 """Base Tornado handlers for the notebook server."""
1 """Base Tornado handlers for the notebook server."""
2
2
3 # Copyright (c) IPython Development Team.
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
4 # Distributed under the terms of the Modified BSD License.
5
5
6 import functools
6 import functools
7 import json
7 import json
8 import logging
8 import logging
9 import os
9 import os
10 import re
10 import re
11 import sys
11 import sys
12 import traceback
12 import traceback
13 try:
13 try:
14 # py3
14 # py3
15 from http.client import responses
15 from http.client import responses
16 except ImportError:
16 except ImportError:
17 from httplib import responses
17 from httplib import responses
18
18
19 from jinja2 import TemplateNotFound
19 from jinja2 import TemplateNotFound
20 from tornado import web
20 from tornado import web
21
21
22 from tornado import gen
22 from tornado import gen
23 from tornado.log import app_log
23 from tornado.log import app_log
24
24
25
25
26 import IPython
26 import IPython
27 from IPython.utils.sysinfo import get_sys_info
27 from IPython.utils.sysinfo import get_sys_info
28
28
29 from IPython.config import Application
29 from IPython.config import Application
30 from IPython.utils.path import filefind
30 from IPython.utils.path import filefind
31 from IPython.utils.py3compat import string_types
31 from IPython.utils.py3compat import string_types
32 from IPython.html.utils import is_hidden, url_path_join, url_escape
32 from IPython.html.utils import is_hidden, url_path_join, url_escape
33
33
34 from IPython.html.services.security import csp_report_uri
34 from IPython.html.services.security import csp_report_uri
35
35
36 #-----------------------------------------------------------------------------
36 #-----------------------------------------------------------------------------
37 # Top-level handlers
37 # Top-level handlers
38 #-----------------------------------------------------------------------------
38 #-----------------------------------------------------------------------------
39 non_alphanum = re.compile(r'[^A-Za-z0-9]')
39 non_alphanum = re.compile(r'[^A-Za-z0-9]')
40
40
41 sys_info = json.dumps(get_sys_info())
41 sys_info = json.dumps(get_sys_info())
42
42
43 class AuthenticatedHandler(web.RequestHandler):
43 class AuthenticatedHandler(web.RequestHandler):
44 """A RequestHandler with an authenticated user."""
44 """A RequestHandler with an authenticated user."""
45
46 @property
47 def content_security_policy(self):
48 """The default Content-Security-Policy header
49
50 Can be overridden by defining Content-Security-Policy in settings['headers']
51 """
52 return '; '.join([
53 "frame-ancestors 'self'",
54 # Make sure the report-uri is relative to the base_url
55 "report-uri " + url_path_join(self.base_url, csp_report_uri),
56 ])
45
57
46 def set_default_headers(self):
58 def set_default_headers(self):
47 headers = self.settings.get('headers', {})
59 headers = self.settings.get('headers', {})
48
60
49 if "Content-Security-Policy" not in headers:
61 if "Content-Security-Policy" not in headers:
50 headers["Content-Security-Policy"] = (
62 headers["Content-Security-Policy"] = self.content_security_policy
51 "frame-ancestors 'self'; "
52 # Make sure the report-uri is relative to the base_url
53 "report-uri " + url_path_join(self.base_url, csp_report_uri) + ";"
54 )
55
63
56 # Allow for overriding headers
64 # Allow for overriding headers
57 for header_name,value in headers.items() :
65 for header_name,value in headers.items() :
58 try:
66 try:
59 self.set_header(header_name, value)
67 self.set_header(header_name, value)
60 except Exception as e:
68 except Exception as e:
61 # tornado raise Exception (not a subclass)
69 # tornado raise Exception (not a subclass)
62 # if method is unsupported (websocket and Access-Control-Allow-Origin
70 # if method is unsupported (websocket and Access-Control-Allow-Origin
63 # for example, so just ignore)
71 # for example, so just ignore)
64 self.log.debug(e)
72 self.log.debug(e)
65
73
66 def clear_login_cookie(self):
74 def clear_login_cookie(self):
67 self.clear_cookie(self.cookie_name)
75 self.clear_cookie(self.cookie_name)
68
76
69 def get_current_user(self):
77 def get_current_user(self):
70 if self.login_handler is None:
78 if self.login_handler is None:
71 return 'anonymous'
79 return 'anonymous'
72 return self.login_handler.get_user(self)
80 return self.login_handler.get_user(self)
73
81
74 @property
82 @property
75 def cookie_name(self):
83 def cookie_name(self):
76 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
84 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
77 self.request.host
85 self.request.host
78 ))
86 ))
79 return self.settings.get('cookie_name', default_cookie_name)
87 return self.settings.get('cookie_name', default_cookie_name)
80
88
81 @property
89 @property
82 def logged_in(self):
90 def logged_in(self):
83 """Is a user currently logged in?"""
91 """Is a user currently logged in?"""
84 user = self.get_current_user()
92 user = self.get_current_user()
85 return (user and not user == 'anonymous')
93 return (user and not user == 'anonymous')
86
94
87 @property
95 @property
88 def login_handler(self):
96 def login_handler(self):
89 """Return the login handler for this application, if any."""
97 """Return the login handler for this application, if any."""
90 return self.settings.get('login_handler_class', None)
98 return self.settings.get('login_handler_class', None)
91
99
92 @property
100 @property
93 def login_available(self):
101 def login_available(self):
94 """May a user proceed to log in?
102 """May a user proceed to log in?
95
103
96 This returns True if login capability is available, irrespective of
104 This returns True if login capability is available, irrespective of
97 whether the user is already logged in or not.
105 whether the user is already logged in or not.
98
106
99 """
107 """
100 if self.login_handler is None:
108 if self.login_handler is None:
101 return False
109 return False
102 return bool(self.login_handler.login_available(self.settings))
110 return bool(self.login_handler.login_available(self.settings))
103
111
104
112
105 class IPythonHandler(AuthenticatedHandler):
113 class IPythonHandler(AuthenticatedHandler):
106 """IPython-specific extensions to authenticated handling
114 """IPython-specific extensions to authenticated handling
107
115
108 Mostly property shortcuts to IPython-specific settings.
116 Mostly property shortcuts to IPython-specific settings.
109 """
117 """
110
118
111 @property
119 @property
112 def config(self):
120 def config(self):
113 return self.settings.get('config', None)
121 return self.settings.get('config', None)
114
122
115 @property
123 @property
116 def log(self):
124 def log(self):
117 """use the IPython log by default, falling back on tornado's logger"""
125 """use the IPython log by default, falling back on tornado's logger"""
118 if Application.initialized():
126 if Application.initialized():
119 return Application.instance().log
127 return Application.instance().log
120 else:
128 else:
121 return app_log
129 return app_log
122
130
123 @property
131 @property
124 def jinja_template_vars(self):
132 def jinja_template_vars(self):
125 """User-supplied values to supply to jinja templates."""
133 """User-supplied values to supply to jinja templates."""
126 return self.settings.get('jinja_template_vars', {})
134 return self.settings.get('jinja_template_vars', {})
127
135
128 #---------------------------------------------------------------
136 #---------------------------------------------------------------
129 # URLs
137 # URLs
130 #---------------------------------------------------------------
138 #---------------------------------------------------------------
131
139
132 @property
140 @property
133 def version_hash(self):
141 def version_hash(self):
134 """The version hash to use for cache hints for static files"""
142 """The version hash to use for cache hints for static files"""
135 return self.settings.get('version_hash', '')
143 return self.settings.get('version_hash', '')
136
144
137 @property
145 @property
138 def mathjax_url(self):
146 def mathjax_url(self):
139 return self.settings.get('mathjax_url', '')
147 return self.settings.get('mathjax_url', '')
140
148
141 @property
149 @property
142 def base_url(self):
150 def base_url(self):
143 return self.settings.get('base_url', '/')
151 return self.settings.get('base_url', '/')
144
152
145 @property
153 @property
146 def default_url(self):
154 def default_url(self):
147 return self.settings.get('default_url', '')
155 return self.settings.get('default_url', '')
148
156
149 @property
157 @property
150 def ws_url(self):
158 def ws_url(self):
151 return self.settings.get('websocket_url', '')
159 return self.settings.get('websocket_url', '')
152
160
153 @property
161 @property
154 def contents_js_source(self):
162 def contents_js_source(self):
155 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
163 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
156 'services/contents'))
164 'services/contents'))
157 return self.settings.get('contents_js_source', 'services/contents')
165 return self.settings.get('contents_js_source', 'services/contents')
158
166
159 #---------------------------------------------------------------
167 #---------------------------------------------------------------
160 # Manager objects
168 # Manager objects
161 #---------------------------------------------------------------
169 #---------------------------------------------------------------
162
170
163 @property
171 @property
164 def kernel_manager(self):
172 def kernel_manager(self):
165 return self.settings['kernel_manager']
173 return self.settings['kernel_manager']
166
174
167 @property
175 @property
168 def contents_manager(self):
176 def contents_manager(self):
169 return self.settings['contents_manager']
177 return self.settings['contents_manager']
170
178
171 @property
179 @property
172 def cluster_manager(self):
180 def cluster_manager(self):
173 return self.settings['cluster_manager']
181 return self.settings['cluster_manager']
174
182
175 @property
183 @property
176 def session_manager(self):
184 def session_manager(self):
177 return self.settings['session_manager']
185 return self.settings['session_manager']
178
186
179 @property
187 @property
180 def terminal_manager(self):
188 def terminal_manager(self):
181 return self.settings['terminal_manager']
189 return self.settings['terminal_manager']
182
190
183 @property
191 @property
184 def kernel_spec_manager(self):
192 def kernel_spec_manager(self):
185 return self.settings['kernel_spec_manager']
193 return self.settings['kernel_spec_manager']
186
194
187 @property
195 @property
188 def config_manager(self):
196 def config_manager(self):
189 return self.settings['config_manager']
197 return self.settings['config_manager']
190
198
191 #---------------------------------------------------------------
199 #---------------------------------------------------------------
192 # CORS
200 # CORS
193 #---------------------------------------------------------------
201 #---------------------------------------------------------------
194
202
195 @property
203 @property
196 def allow_origin(self):
204 def allow_origin(self):
197 """Normal Access-Control-Allow-Origin"""
205 """Normal Access-Control-Allow-Origin"""
198 return self.settings.get('allow_origin', '')
206 return self.settings.get('allow_origin', '')
199
207
200 @property
208 @property
201 def allow_origin_pat(self):
209 def allow_origin_pat(self):
202 """Regular expression version of allow_origin"""
210 """Regular expression version of allow_origin"""
203 return self.settings.get('allow_origin_pat', None)
211 return self.settings.get('allow_origin_pat', None)
204
212
205 @property
213 @property
206 def allow_credentials(self):
214 def allow_credentials(self):
207 """Whether to set Access-Control-Allow-Credentials"""
215 """Whether to set Access-Control-Allow-Credentials"""
208 return self.settings.get('allow_credentials', False)
216 return self.settings.get('allow_credentials', False)
209
217
210 def set_default_headers(self):
218 def set_default_headers(self):
211 """Add CORS headers, if defined"""
219 """Add CORS headers, if defined"""
212 super(IPythonHandler, self).set_default_headers()
220 super(IPythonHandler, self).set_default_headers()
213 if self.allow_origin:
221 if self.allow_origin:
214 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
222 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
215 elif self.allow_origin_pat:
223 elif self.allow_origin_pat:
216 origin = self.get_origin()
224 origin = self.get_origin()
217 if origin and self.allow_origin_pat.match(origin):
225 if origin and self.allow_origin_pat.match(origin):
218 self.set_header("Access-Control-Allow-Origin", origin)
226 self.set_header("Access-Control-Allow-Origin", origin)
219 if self.allow_credentials:
227 if self.allow_credentials:
220 self.set_header("Access-Control-Allow-Credentials", 'true')
228 self.set_header("Access-Control-Allow-Credentials", 'true')
221
229
222 def get_origin(self):
230 def get_origin(self):
223 # Handle WebSocket Origin naming convention differences
231 # Handle WebSocket Origin naming convention differences
224 # The difference between version 8 and 13 is that in 8 the
232 # The difference between version 8 and 13 is that in 8 the
225 # client sends a "Sec-Websocket-Origin" header and in 13 it's
233 # client sends a "Sec-Websocket-Origin" header and in 13 it's
226 # simply "Origin".
234 # simply "Origin".
227 if "Origin" in self.request.headers:
235 if "Origin" in self.request.headers:
228 origin = self.request.headers.get("Origin")
236 origin = self.request.headers.get("Origin")
229 else:
237 else:
230 origin = self.request.headers.get("Sec-Websocket-Origin", None)
238 origin = self.request.headers.get("Sec-Websocket-Origin", None)
231 return origin
239 return origin
232
240
233 #---------------------------------------------------------------
241 #---------------------------------------------------------------
234 # template rendering
242 # template rendering
235 #---------------------------------------------------------------
243 #---------------------------------------------------------------
236
244
237 def get_template(self, name):
245 def get_template(self, name):
238 """Return the jinja template object for a given name"""
246 """Return the jinja template object for a given name"""
239 return self.settings['jinja2_env'].get_template(name)
247 return self.settings['jinja2_env'].get_template(name)
240
248
241 def render_template(self, name, **ns):
249 def render_template(self, name, **ns):
242 ns.update(self.template_namespace)
250 ns.update(self.template_namespace)
243 template = self.get_template(name)
251 template = self.get_template(name)
244 return template.render(**ns)
252 return template.render(**ns)
245
253
246 @property
254 @property
247 def template_namespace(self):
255 def template_namespace(self):
248 return dict(
256 return dict(
249 base_url=self.base_url,
257 base_url=self.base_url,
250 default_url=self.default_url,
258 default_url=self.default_url,
251 ws_url=self.ws_url,
259 ws_url=self.ws_url,
252 logged_in=self.logged_in,
260 logged_in=self.logged_in,
253 login_available=self.login_available,
261 login_available=self.login_available,
254 static_url=self.static_url,
262 static_url=self.static_url,
255 sys_info=sys_info,
263 sys_info=sys_info,
256 contents_js_source=self.contents_js_source,
264 contents_js_source=self.contents_js_source,
257 version_hash=self.version_hash,
265 version_hash=self.version_hash,
258 **self.jinja_template_vars
266 **self.jinja_template_vars
259 )
267 )
260
268
261 def get_json_body(self):
269 def get_json_body(self):
262 """Return the body of the request as JSON data."""
270 """Return the body of the request as JSON data."""
263 if not self.request.body:
271 if not self.request.body:
264 return None
272 return None
265 # Do we need to call body.decode('utf-8') here?
273 # Do we need to call body.decode('utf-8') here?
266 body = self.request.body.strip().decode(u'utf-8')
274 body = self.request.body.strip().decode(u'utf-8')
267 try:
275 try:
268 model = json.loads(body)
276 model = json.loads(body)
269 except Exception:
277 except Exception:
270 self.log.debug("Bad JSON: %r", body)
278 self.log.debug("Bad JSON: %r", body)
271 self.log.error("Couldn't parse JSON", exc_info=True)
279 self.log.error("Couldn't parse JSON", exc_info=True)
272 raise web.HTTPError(400, u'Invalid JSON in body of request')
280 raise web.HTTPError(400, u'Invalid JSON in body of request')
273 return model
281 return model
274
282
275 def write_error(self, status_code, **kwargs):
283 def write_error(self, status_code, **kwargs):
276 """render custom error pages"""
284 """render custom error pages"""
277 exc_info = kwargs.get('exc_info')
285 exc_info = kwargs.get('exc_info')
278 message = ''
286 message = ''
279 status_message = responses.get(status_code, 'Unknown HTTP Error')
287 status_message = responses.get(status_code, 'Unknown HTTP Error')
280 if exc_info:
288 if exc_info:
281 exception = exc_info[1]
289 exception = exc_info[1]
282 # get the custom message, if defined
290 # get the custom message, if defined
283 try:
291 try:
284 message = exception.log_message % exception.args
292 message = exception.log_message % exception.args
285 except Exception:
293 except Exception:
286 pass
294 pass
287
295
288 # construct the custom reason, if defined
296 # construct the custom reason, if defined
289 reason = getattr(exception, 'reason', '')
297 reason = getattr(exception, 'reason', '')
290 if reason:
298 if reason:
291 status_message = reason
299 status_message = reason
292
300
293 # build template namespace
301 # build template namespace
294 ns = dict(
302 ns = dict(
295 status_code=status_code,
303 status_code=status_code,
296 status_message=status_message,
304 status_message=status_message,
297 message=message,
305 message=message,
298 exception=exception,
306 exception=exception,
299 )
307 )
300
308
301 self.set_header('Content-Type', 'text/html')
309 self.set_header('Content-Type', 'text/html')
302 # render the template
310 # render the template
303 try:
311 try:
304 html = self.render_template('%s.html' % status_code, **ns)
312 html = self.render_template('%s.html' % status_code, **ns)
305 except TemplateNotFound:
313 except TemplateNotFound:
306 self.log.debug("No template for %d", status_code)
314 self.log.debug("No template for %d", status_code)
307 html = self.render_template('error.html', **ns)
315 html = self.render_template('error.html', **ns)
308
316
309 self.write(html)
317 self.write(html)
310
318
311
319
312 class APIHandler(IPythonHandler):
320 class APIHandler(IPythonHandler):
313 """Base class for API handlers"""
321 """Base class for API handlers"""
322
323 @property
324 def content_security_policy(self):
325 csp = '; '.join([
326 super(APIHandler, self).content_security_policy,
327 "default-src 'none'",
328 ])
329 return csp
330
314 def finish(self, *args, **kwargs):
331 def finish(self, *args, **kwargs):
315 self.set_header('Content-Security-Policy', "default-src 'none'")
316 self.set_header('Content-Type', 'application/json')
332 self.set_header('Content-Type', 'application/json')
317 return super(APIHandler, self).finish(*args, **kwargs)
333 return super(APIHandler, self).finish(*args, **kwargs)
318
334
319
335
320 class Template404(IPythonHandler):
336 class Template404(IPythonHandler):
321 """Render our 404 template"""
337 """Render our 404 template"""
322 def prepare(self):
338 def prepare(self):
323 raise web.HTTPError(404)
339 raise web.HTTPError(404)
324
340
325
341
326 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
342 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
327 """static files should only be accessible when logged in"""
343 """static files should only be accessible when logged in"""
328
344
329 @web.authenticated
345 @web.authenticated
330 def get(self, path):
346 def get(self, path):
331 if os.path.splitext(path)[1] == '.ipynb':
347 if os.path.splitext(path)[1] == '.ipynb':
332 name = path.rsplit('/', 1)[-1]
348 name = path.rsplit('/', 1)[-1]
333 self.set_header('Content-Type', 'application/json')
349 self.set_header('Content-Type', 'application/json')
334 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
350 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
335
351
336 return web.StaticFileHandler.get(self, path)
352 return web.StaticFileHandler.get(self, path)
337
353
338 def set_headers(self):
354 def set_headers(self):
339 super(AuthenticatedFileHandler, self).set_headers()
355 super(AuthenticatedFileHandler, self).set_headers()
340 # disable browser caching, rely on 304 replies for savings
356 # disable browser caching, rely on 304 replies for savings
341 if "v" not in self.request.arguments:
357 if "v" not in self.request.arguments:
342 self.add_header("Cache-Control", "no-cache")
358 self.add_header("Cache-Control", "no-cache")
343
359
344 def compute_etag(self):
360 def compute_etag(self):
345 return None
361 return None
346
362
347 def validate_absolute_path(self, root, absolute_path):
363 def validate_absolute_path(self, root, absolute_path):
348 """Validate and return the absolute path.
364 """Validate and return the absolute path.
349
365
350 Requires tornado 3.1
366 Requires tornado 3.1
351
367
352 Adding to tornado's own handling, forbids the serving of hidden files.
368 Adding to tornado's own handling, forbids the serving of hidden files.
353 """
369 """
354 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
370 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
355 abs_root = os.path.abspath(root)
371 abs_root = os.path.abspath(root)
356 if is_hidden(abs_path, abs_root):
372 if is_hidden(abs_path, abs_root):
357 self.log.info("Refusing to serve hidden file, via 404 Error")
373 self.log.info("Refusing to serve hidden file, via 404 Error")
358 raise web.HTTPError(404)
374 raise web.HTTPError(404)
359 return abs_path
375 return abs_path
360
376
361
377
362 def json_errors(method):
378 def json_errors(method):
363 """Decorate methods with this to return GitHub style JSON errors.
379 """Decorate methods with this to return GitHub style JSON errors.
364
380
365 This should be used on any JSON API on any handler method that can raise HTTPErrors.
381 This should be used on any JSON API on any handler method that can raise HTTPErrors.
366
382
367 This will grab the latest HTTPError exception using sys.exc_info
383 This will grab the latest HTTPError exception using sys.exc_info
368 and then:
384 and then:
369
385
370 1. Set the HTTP status code based on the HTTPError
386 1. Set the HTTP status code based on the HTTPError
371 2. Create and return a JSON body with a message field describing
387 2. Create and return a JSON body with a message field describing
372 the error in a human readable form.
388 the error in a human readable form.
373 """
389 """
374 @functools.wraps(method)
390 @functools.wraps(method)
375 @gen.coroutine
391 @gen.coroutine
376 def wrapper(self, *args, **kwargs):
392 def wrapper(self, *args, **kwargs):
377 try:
393 try:
378 result = yield gen.maybe_future(method(self, *args, **kwargs))
394 result = yield gen.maybe_future(method(self, *args, **kwargs))
379 except web.HTTPError as e:
395 except web.HTTPError as e:
380 self.set_header('Content-Type', 'application/json')
396 self.set_header('Content-Type', 'application/json')
381 status = e.status_code
397 status = e.status_code
382 message = e.log_message
398 message = e.log_message
383 self.log.warn(message)
399 self.log.warn(message)
384 self.set_status(e.status_code)
400 self.set_status(e.status_code)
385 reply = dict(message=message, reason=e.reason)
401 reply = dict(message=message, reason=e.reason)
386 self.finish(json.dumps(reply))
402 self.finish(json.dumps(reply))
387 except Exception:
403 except Exception:
388 self.set_header('Content-Type', 'application/json')
404 self.set_header('Content-Type', 'application/json')
389 self.log.error("Unhandled error in API request", exc_info=True)
405 self.log.error("Unhandled error in API request", exc_info=True)
390 status = 500
406 status = 500
391 message = "Unknown server error"
407 message = "Unknown server error"
392 t, value, tb = sys.exc_info()
408 t, value, tb = sys.exc_info()
393 self.set_status(status)
409 self.set_status(status)
394 tb_text = ''.join(traceback.format_exception(t, value, tb))
410 tb_text = ''.join(traceback.format_exception(t, value, tb))
395 reply = dict(message=message, reason=None, traceback=tb_text)
411 reply = dict(message=message, reason=None, traceback=tb_text)
396 self.finish(json.dumps(reply))
412 self.finish(json.dumps(reply))
397 else:
413 else:
398 # FIXME: can use regular return in generators in py3
414 # FIXME: can use regular return in generators in py3
399 raise gen.Return(result)
415 raise gen.Return(result)
400 return wrapper
416 return wrapper
401
417
402
418
403
419
404 #-----------------------------------------------------------------------------
420 #-----------------------------------------------------------------------------
405 # File handler
421 # File handler
406 #-----------------------------------------------------------------------------
422 #-----------------------------------------------------------------------------
407
423
408 # to minimize subclass changes:
424 # to minimize subclass changes:
409 HTTPError = web.HTTPError
425 HTTPError = web.HTTPError
410
426
411 class FileFindHandler(web.StaticFileHandler):
427 class FileFindHandler(web.StaticFileHandler):
412 """subclass of StaticFileHandler for serving files from a search path"""
428 """subclass of StaticFileHandler for serving files from a search path"""
413
429
414 # cache search results, don't search for files more than once
430 # cache search results, don't search for files more than once
415 _static_paths = {}
431 _static_paths = {}
416
432
417 def set_headers(self):
433 def set_headers(self):
418 super(FileFindHandler, self).set_headers()
434 super(FileFindHandler, self).set_headers()
419 # disable browser caching, rely on 304 replies for savings
435 # disable browser caching, rely on 304 replies for savings
420 if "v" not in self.request.arguments or \
436 if "v" not in self.request.arguments or \
421 any(self.request.path.startswith(path) for path in self.no_cache_paths):
437 any(self.request.path.startswith(path) for path in self.no_cache_paths):
422 self.set_header("Cache-Control", "no-cache")
438 self.set_header("Cache-Control", "no-cache")
423
439
424 def initialize(self, path, default_filename=None, no_cache_paths=None):
440 def initialize(self, path, default_filename=None, no_cache_paths=None):
425 self.no_cache_paths = no_cache_paths or []
441 self.no_cache_paths = no_cache_paths or []
426
442
427 if isinstance(path, string_types):
443 if isinstance(path, string_types):
428 path = [path]
444 path = [path]
429
445
430 self.root = tuple(
446 self.root = tuple(
431 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
447 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
432 )
448 )
433 self.default_filename = default_filename
449 self.default_filename = default_filename
434
450
435 def compute_etag(self):
451 def compute_etag(self):
436 return None
452 return None
437
453
438 @classmethod
454 @classmethod
439 def get_absolute_path(cls, roots, path):
455 def get_absolute_path(cls, roots, path):
440 """locate a file to serve on our static file search path"""
456 """locate a file to serve on our static file search path"""
441 with cls._lock:
457 with cls._lock:
442 if path in cls._static_paths:
458 if path in cls._static_paths:
443 return cls._static_paths[path]
459 return cls._static_paths[path]
444 try:
460 try:
445 abspath = os.path.abspath(filefind(path, roots))
461 abspath = os.path.abspath(filefind(path, roots))
446 except IOError:
462 except IOError:
447 # IOError means not found
463 # IOError means not found
448 return ''
464 return ''
449
465
450 cls._static_paths[path] = abspath
466 cls._static_paths[path] = abspath
451 return abspath
467 return abspath
452
468
453 def validate_absolute_path(self, root, absolute_path):
469 def validate_absolute_path(self, root, absolute_path):
454 """check if the file should be served (raises 404, 403, etc.)"""
470 """check if the file should be served (raises 404, 403, etc.)"""
455 if absolute_path == '':
471 if absolute_path == '':
456 raise web.HTTPError(404)
472 raise web.HTTPError(404)
457
473
458 for root in self.root:
474 for root in self.root:
459 if (absolute_path + os.sep).startswith(root):
475 if (absolute_path + os.sep).startswith(root):
460 break
476 break
461
477
462 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
478 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
463
479
464
480
465 class APIVersionHandler(APIHandler):
481 class APIVersionHandler(APIHandler):
466
482
467 @json_errors
483 @json_errors
468 def get(self):
484 def get(self):
469 # not authenticated, so give as few info as possible
485 # not authenticated, so give as few info as possible
470 self.finish(json.dumps({"version":IPython.__version__}))
486 self.finish(json.dumps({"version":IPython.__version__}))
471
487
472
488
473 class TrailingSlashHandler(web.RequestHandler):
489 class TrailingSlashHandler(web.RequestHandler):
474 """Simple redirect handler that strips trailing slashes
490 """Simple redirect handler that strips trailing slashes
475
491
476 This should be the first, highest priority handler.
492 This should be the first, highest priority handler.
477 """
493 """
478
494
479 def get(self):
495 def get(self):
480 self.redirect(self.request.uri.rstrip('/'))
496 self.redirect(self.request.uri.rstrip('/'))
481
497
482 post = put = get
498 post = put = get
483
499
484
500
485 class FilesRedirectHandler(IPythonHandler):
501 class FilesRedirectHandler(IPythonHandler):
486 """Handler for redirecting relative URLs to the /files/ handler"""
502 """Handler for redirecting relative URLs to the /files/ handler"""
487
503
488 @staticmethod
504 @staticmethod
489 def redirect_to_files(self, path):
505 def redirect_to_files(self, path):
490 """make redirect logic a reusable static method
506 """make redirect logic a reusable static method
491
507
492 so it can be called from other handlers.
508 so it can be called from other handlers.
493 """
509 """
494 cm = self.contents_manager
510 cm = self.contents_manager
495 if cm.dir_exists(path):
511 if cm.dir_exists(path):
496 # it's a *directory*, redirect to /tree
512 # it's a *directory*, redirect to /tree
497 url = url_path_join(self.base_url, 'tree', path)
513 url = url_path_join(self.base_url, 'tree', path)
498 else:
514 else:
499 orig_path = path
515 orig_path = path
500 # otherwise, redirect to /files
516 # otherwise, redirect to /files
501 parts = path.split('/')
517 parts = path.split('/')
502
518
503 if not cm.file_exists(path=path) and 'files' in parts:
519 if not cm.file_exists(path=path) and 'files' in parts:
504 # redirect without files/ iff it would 404
520 # redirect without files/ iff it would 404
505 # this preserves pre-2.0-style 'files/' links
521 # this preserves pre-2.0-style 'files/' links
506 self.log.warn("Deprecated files/ URL: %s", orig_path)
522 self.log.warn("Deprecated files/ URL: %s", orig_path)
507 parts.remove('files')
523 parts.remove('files')
508 path = '/'.join(parts)
524 path = '/'.join(parts)
509
525
510 if not cm.file_exists(path=path):
526 if not cm.file_exists(path=path):
511 raise web.HTTPError(404)
527 raise web.HTTPError(404)
512
528
513 url = url_path_join(self.base_url, 'files', path)
529 url = url_path_join(self.base_url, 'files', path)
514 url = url_escape(url)
530 url = url_escape(url)
515 self.log.debug("Redirecting %s to %s", self.request.path, url)
531 self.log.debug("Redirecting %s to %s", self.request.path, url)
516 self.redirect(url)
532 self.redirect(url)
517
533
518 def get(self, path=''):
534 def get(self, path=''):
519 return self.redirect_to_files(self, path)
535 return self.redirect_to_files(self, path)
520
536
521
537
522 #-----------------------------------------------------------------------------
538 #-----------------------------------------------------------------------------
523 # URL pattern fragments for re-use
539 # URL pattern fragments for re-use
524 #-----------------------------------------------------------------------------
540 #-----------------------------------------------------------------------------
525
541
526 # path matches any number of `/foo[/bar...]` or just `/` or ''
542 # path matches any number of `/foo[/bar...]` or just `/` or ''
527 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
543 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
528
544
529 #-----------------------------------------------------------------------------
545 #-----------------------------------------------------------------------------
530 # URL to handler mappings
546 # URL to handler mappings
531 #-----------------------------------------------------------------------------
547 #-----------------------------------------------------------------------------
532
548
533
549
534 default_handlers = [
550 default_handlers = [
535 (r".*/", TrailingSlashHandler),
551 (r".*/", TrailingSlashHandler),
536 (r"api", APIVersionHandler)
552 (r"api", APIVersionHandler)
537 ]
553 ]
@@ -1,139 +1,141 b''
1 """Test the kernels service API."""
1 """Test the kernels service API."""
2
2
3 import json
3 import json
4 import requests
4 import requests
5
5
6 from IPython.html.utils import url_path_join
6 from IPython.html.utils import url_path_join
7 from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error
7 from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error
8
8
9 class KernelAPI(object):
9 class KernelAPI(object):
10 """Wrapper for kernel REST API requests"""
10 """Wrapper for kernel REST API requests"""
11 def __init__(self, base_url):
11 def __init__(self, base_url):
12 self.base_url = base_url
12 self.base_url = base_url
13
13
14 def _req(self, verb, path, body=None):
14 def _req(self, verb, path, body=None):
15 response = requests.request(verb,
15 response = requests.request(verb,
16 url_path_join(self.base_url, 'api/kernels', path), data=body)
16 url_path_join(self.base_url, 'api/kernels', path), data=body)
17
17
18 if 400 <= response.status_code < 600:
18 if 400 <= response.status_code < 600:
19 try:
19 try:
20 response.reason = response.json()['message']
20 response.reason = response.json()['message']
21 except:
21 except:
22 pass
22 pass
23 response.raise_for_status()
23 response.raise_for_status()
24
24
25 return response
25 return response
26
26
27 def list(self):
27 def list(self):
28 return self._req('GET', '')
28 return self._req('GET', '')
29
29
30 def get(self, id):
30 def get(self, id):
31 return self._req('GET', id)
31 return self._req('GET', id)
32
32
33 def start(self, name='python'):
33 def start(self, name='python'):
34 body = json.dumps({'name': name})
34 body = json.dumps({'name': name})
35 return self._req('POST', '', body)
35 return self._req('POST', '', body)
36
36
37 def shutdown(self, id):
37 def shutdown(self, id):
38 return self._req('DELETE', id)
38 return self._req('DELETE', id)
39
39
40 def interrupt(self, id):
40 def interrupt(self, id):
41 return self._req('POST', url_path_join(id, 'interrupt'))
41 return self._req('POST', url_path_join(id, 'interrupt'))
42
42
43 def restart(self, id):
43 def restart(self, id):
44 return self._req('POST', url_path_join(id, 'restart'))
44 return self._req('POST', url_path_join(id, 'restart'))
45
45
46 class KernelAPITest(NotebookTestBase):
46 class KernelAPITest(NotebookTestBase):
47 """Test the kernels web service API"""
47 """Test the kernels web service API"""
48 def setUp(self):
48 def setUp(self):
49 self.kern_api = KernelAPI(self.base_url())
49 self.kern_api = KernelAPI(self.base_url())
50
50
51 def tearDown(self):
51 def tearDown(self):
52 for k in self.kern_api.list().json():
52 for k in self.kern_api.list().json():
53 self.kern_api.shutdown(k['id'])
53 self.kern_api.shutdown(k['id'])
54
54
55 def test__no_kernels(self):
55 def test__no_kernels(self):
56 """Make sure there are no kernels running at the start"""
56 """Make sure there are no kernels running at the start"""
57 kernels = self.kern_api.list().json()
57 kernels = self.kern_api.list().json()
58 self.assertEqual(kernels, [])
58 self.assertEqual(kernels, [])
59
59
60 def test_default_kernel(self):
60 def test_default_kernel(self):
61 # POST request
61 # POST request
62 r = self.kern_api._req('POST', '')
62 r = self.kern_api._req('POST', '')
63 kern1 = r.json()
63 kern1 = r.json()
64 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
64 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
65 self.assertEqual(r.status_code, 201)
65 self.assertEqual(r.status_code, 201)
66 self.assertIsInstance(kern1, dict)
66 self.assertIsInstance(kern1, dict)
67
67
68 self.assertEqual(r.headers['Content-Security-Policy'], (
68 self.assertEqual(r.headers['Content-Security-Policy'], (
69 "frame-ancestors 'self'; "
69 "frame-ancestors 'self'; "
70 "report-uri /api/security/csp-report;"
70 "report-uri /api/security/csp-report; "
71 "default-src 'none'"
71 ))
72 ))
72
73
73 def test_main_kernel_handler(self):
74 def test_main_kernel_handler(self):
74 # POST request
75 # POST request
75 r = self.kern_api.start()
76 r = self.kern_api.start()
76 kern1 = r.json()
77 kern1 = r.json()
77 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
78 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
78 self.assertEqual(r.status_code, 201)
79 self.assertEqual(r.status_code, 201)
79 self.assertIsInstance(kern1, dict)
80 self.assertIsInstance(kern1, dict)
80
81
81 self.assertEqual(r.headers['Content-Security-Policy'], (
82 self.assertEqual(r.headers['Content-Security-Policy'], (
82 "frame-ancestors 'self'; "
83 "frame-ancestors 'self'; "
83 "report-uri /api/security/csp-report;"
84 "report-uri /api/security/csp-report; "
85 "default-src 'none'"
84 ))
86 ))
85
87
86 # GET request
88 # GET request
87 r = self.kern_api.list()
89 r = self.kern_api.list()
88 self.assertEqual(r.status_code, 200)
90 self.assertEqual(r.status_code, 200)
89 assert isinstance(r.json(), list)
91 assert isinstance(r.json(), list)
90 self.assertEqual(r.json()[0]['id'], kern1['id'])
92 self.assertEqual(r.json()[0]['id'], kern1['id'])
91 self.assertEqual(r.json()[0]['name'], kern1['name'])
93 self.assertEqual(r.json()[0]['name'], kern1['name'])
92
94
93 # create another kernel and check that they both are added to the
95 # create another kernel and check that they both are added to the
94 # list of kernels from a GET request
96 # list of kernels from a GET request
95 kern2 = self.kern_api.start().json()
97 kern2 = self.kern_api.start().json()
96 assert isinstance(kern2, dict)
98 assert isinstance(kern2, dict)
97 r = self.kern_api.list()
99 r = self.kern_api.list()
98 kernels = r.json()
100 kernels = r.json()
99 self.assertEqual(r.status_code, 200)
101 self.assertEqual(r.status_code, 200)
100 assert isinstance(kernels, list)
102 assert isinstance(kernels, list)
101 self.assertEqual(len(kernels), 2)
103 self.assertEqual(len(kernels), 2)
102
104
103 # Interrupt a kernel
105 # Interrupt a kernel
104 r = self.kern_api.interrupt(kern2['id'])
106 r = self.kern_api.interrupt(kern2['id'])
105 self.assertEqual(r.status_code, 204)
107 self.assertEqual(r.status_code, 204)
106
108
107 # Restart a kernel
109 # Restart a kernel
108 r = self.kern_api.restart(kern2['id'])
110 r = self.kern_api.restart(kern2['id'])
109 self.assertEqual(r.headers['Location'], '/api/kernels/'+kern2['id'])
111 self.assertEqual(r.headers['Location'], '/api/kernels/'+kern2['id'])
110 rekern = r.json()
112 rekern = r.json()
111 self.assertEqual(rekern['id'], kern2['id'])
113 self.assertEqual(rekern['id'], kern2['id'])
112 self.assertEqual(rekern['name'], kern2['name'])
114 self.assertEqual(rekern['name'], kern2['name'])
113
115
114 def test_kernel_handler(self):
116 def test_kernel_handler(self):
115 # GET kernel with given id
117 # GET kernel with given id
116 kid = self.kern_api.start().json()['id']
118 kid = self.kern_api.start().json()['id']
117 r = self.kern_api.get(kid)
119 r = self.kern_api.get(kid)
118 kern1 = r.json()
120 kern1 = r.json()
119 self.assertEqual(r.status_code, 200)
121 self.assertEqual(r.status_code, 200)
120 assert isinstance(kern1, dict)
122 assert isinstance(kern1, dict)
121 self.assertIn('id', kern1)
123 self.assertIn('id', kern1)
122 self.assertEqual(kern1['id'], kid)
124 self.assertEqual(kern1['id'], kid)
123
125
124 # Request a bad kernel id and check that a JSON
126 # Request a bad kernel id and check that a JSON
125 # message is returned!
127 # message is returned!
126 bad_id = '111-111-111-111-111'
128 bad_id = '111-111-111-111-111'
127 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
129 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
128 self.kern_api.get(bad_id)
130 self.kern_api.get(bad_id)
129
131
130 # DELETE kernel with id
132 # DELETE kernel with id
131 r = self.kern_api.shutdown(kid)
133 r = self.kern_api.shutdown(kid)
132 self.assertEqual(r.status_code, 204)
134 self.assertEqual(r.status_code, 204)
133 kernels = self.kern_api.list().json()
135 kernels = self.kern_api.list().json()
134 self.assertEqual(kernels, [])
136 self.assertEqual(kernels, [])
135
137
136 # Request to delete a non-existent kernel id
138 # Request to delete a non-existent kernel id
137 bad_id = '111-111-111-111-111'
139 bad_id = '111-111-111-111-111'
138 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
140 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
139 self.kern_api.shutdown(bad_id)
141 self.kern_api.shutdown(bad_id)
General Comments 0
You need to be logged in to leave comments. Login now