##// END OF EJS Templates
Have /api return the IPython version...
Bussonnier Matthias -
Show More
@@ -1,460 +1,470 b''
1 1 """Base Tornado handlers for the notebook server."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import functools
7 7 import json
8 8 import logging
9 9 import os
10 10 import re
11 11 import sys
12 12 import traceback
13 13 try:
14 14 # py3
15 15 from http.client import responses
16 16 except ImportError:
17 17 from httplib import responses
18 18
19 19 from jinja2 import TemplateNotFound
20 20 from tornado import web
21 21
22 22 try:
23 23 from tornado.log import app_log
24 24 except ImportError:
25 25 app_log = logging.getLogger()
26 26
27 import IPython
28
27 29 from IPython.config import Application
28 30 from IPython.utils.path import filefind
29 31 from IPython.utils.py3compat import string_types
30 32 from IPython.html.utils import is_hidden, url_path_join, url_escape
31 33
32 34 #-----------------------------------------------------------------------------
33 35 # Top-level handlers
34 36 #-----------------------------------------------------------------------------
35 37 non_alphanum = re.compile(r'[^A-Za-z0-9]')
36 38
37 39 class AuthenticatedHandler(web.RequestHandler):
38 40 """A RequestHandler with an authenticated user."""
39 41
40 42 def set_default_headers(self):
41 43 headers = self.settings.get('headers', {})
42 44
43 45 if "X-Frame-Options" not in headers:
44 46 headers["X-Frame-Options"] = "SAMEORIGIN"
45 47
46 48 for header_name,value in headers.items() :
47 49 try:
48 50 self.set_header(header_name, value)
49 51 except Exception:
50 52 # tornado raise Exception (not a subclass)
51 53 # if method is unsupported (websocket and Access-Control-Allow-Origin
52 54 # for example, so just ignore)
53 55 pass
54 56
55 57 def clear_login_cookie(self):
56 58 self.clear_cookie(self.cookie_name)
57 59
58 60 def get_current_user(self):
59 61 user_id = self.get_secure_cookie(self.cookie_name)
60 62 # For now the user_id should not return empty, but it could eventually
61 63 if user_id == '':
62 64 user_id = 'anonymous'
63 65 if user_id is None:
64 66 # prevent extra Invalid cookie sig warnings:
65 67 self.clear_login_cookie()
66 68 if not self.login_available:
67 69 user_id = 'anonymous'
68 70 return user_id
69 71
70 72 @property
71 73 def cookie_name(self):
72 74 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
73 75 self.request.host
74 76 ))
75 77 return self.settings.get('cookie_name', default_cookie_name)
76 78
77 79 @property
78 80 def password(self):
79 81 """our password"""
80 82 return self.settings.get('password', '')
81 83
82 84 @property
83 85 def logged_in(self):
84 86 """Is a user currently logged in?
85 87
86 88 """
87 89 user = self.get_current_user()
88 90 return (user and not user == 'anonymous')
89 91
90 92 @property
91 93 def login_available(self):
92 94 """May a user proceed to log in?
93 95
94 96 This returns True if login capability is available, irrespective of
95 97 whether the user is already logged in or not.
96 98
97 99 """
98 100 return bool(self.settings.get('password', ''))
99 101
100 102
101 103 class IPythonHandler(AuthenticatedHandler):
102 104 """IPython-specific extensions to authenticated handling
103 105
104 106 Mostly property shortcuts to IPython-specific settings.
105 107 """
106 108
107 109 @property
108 110 def config(self):
109 111 return self.settings.get('config', None)
110 112
111 113 @property
112 114 def log(self):
113 115 """use the IPython log by default, falling back on tornado's logger"""
114 116 if Application.initialized():
115 117 return Application.instance().log
116 118 else:
117 119 return app_log
118 120
119 121 #---------------------------------------------------------------
120 122 # URLs
121 123 #---------------------------------------------------------------
122 124
123 125 @property
124 126 def mathjax_url(self):
125 127 return self.settings.get('mathjax_url', '')
126 128
127 129 @property
128 130 def base_url(self):
129 131 return self.settings.get('base_url', '/')
130 132
131 133 @property
132 134 def ws_url(self):
133 135 return self.settings.get('websocket_url', '')
134 136
135 137 #---------------------------------------------------------------
136 138 # Manager objects
137 139 #---------------------------------------------------------------
138 140
139 141 @property
140 142 def kernel_manager(self):
141 143 return self.settings['kernel_manager']
142 144
143 145 @property
144 146 def contents_manager(self):
145 147 return self.settings['contents_manager']
146 148
147 149 @property
148 150 def cluster_manager(self):
149 151 return self.settings['cluster_manager']
150 152
151 153 @property
152 154 def session_manager(self):
153 155 return self.settings['session_manager']
154 156
155 157 @property
156 158 def kernel_spec_manager(self):
157 159 return self.settings['kernel_spec_manager']
158 160
159 161 #---------------------------------------------------------------
160 162 # CORS
161 163 #---------------------------------------------------------------
162 164
163 165 @property
164 166 def allow_origin(self):
165 167 """Normal Access-Control-Allow-Origin"""
166 168 return self.settings.get('allow_origin', '')
167 169
168 170 @property
169 171 def allow_origin_pat(self):
170 172 """Regular expression version of allow_origin"""
171 173 return self.settings.get('allow_origin_pat', None)
172 174
173 175 @property
174 176 def allow_credentials(self):
175 177 """Whether to set Access-Control-Allow-Credentials"""
176 178 return self.settings.get('allow_credentials', False)
177 179
178 180 def set_default_headers(self):
179 181 """Add CORS headers, if defined"""
180 182 super(IPythonHandler, self).set_default_headers()
181 183 if self.allow_origin:
182 184 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
183 185 elif self.allow_origin_pat:
184 186 origin = self.get_origin()
185 187 if origin and self.allow_origin_pat.match(origin):
186 188 self.set_header("Access-Control-Allow-Origin", origin)
187 189 if self.allow_credentials:
188 190 self.set_header("Access-Control-Allow-Credentials", 'true')
189 191
190 192 def get_origin(self):
191 193 # Handle WebSocket Origin naming convention differences
192 194 # The difference between version 8 and 13 is that in 8 the
193 195 # client sends a "Sec-Websocket-Origin" header and in 13 it's
194 196 # simply "Origin".
195 197 if "Origin" in self.request.headers:
196 198 origin = self.request.headers.get("Origin")
197 199 else:
198 200 origin = self.request.headers.get("Sec-Websocket-Origin", None)
199 201 return origin
200 202
201 203 #---------------------------------------------------------------
202 204 # template rendering
203 205 #---------------------------------------------------------------
204 206
205 207 def get_template(self, name):
206 208 """Return the jinja template object for a given name"""
207 209 return self.settings['jinja2_env'].get_template(name)
208 210
209 211 def render_template(self, name, **ns):
210 212 ns.update(self.template_namespace)
211 213 template = self.get_template(name)
212 214 return template.render(**ns)
213 215
214 216 @property
215 217 def template_namespace(self):
216 218 return dict(
217 219 base_url=self.base_url,
218 220 ws_url=self.ws_url,
219 221 logged_in=self.logged_in,
220 222 login_available=self.login_available,
221 223 static_url=self.static_url,
222 224 )
223 225
224 226 def get_json_body(self):
225 227 """Return the body of the request as JSON data."""
226 228 if not self.request.body:
227 229 return None
228 230 # Do we need to call body.decode('utf-8') here?
229 231 body = self.request.body.strip().decode(u'utf-8')
230 232 try:
231 233 model = json.loads(body)
232 234 except Exception:
233 235 self.log.debug("Bad JSON: %r", body)
234 236 self.log.error("Couldn't parse JSON", exc_info=True)
235 237 raise web.HTTPError(400, u'Invalid JSON in body of request')
236 238 return model
237 239
238 240 def write_error(self, status_code, **kwargs):
239 241 """render custom error pages"""
240 242 exc_info = kwargs.get('exc_info')
241 243 message = ''
242 244 status_message = responses.get(status_code, 'Unknown HTTP Error')
243 245 if exc_info:
244 246 exception = exc_info[1]
245 247 # get the custom message, if defined
246 248 try:
247 249 message = exception.log_message % exception.args
248 250 except Exception:
249 251 pass
250 252
251 253 # construct the custom reason, if defined
252 254 reason = getattr(exception, 'reason', '')
253 255 if reason:
254 256 status_message = reason
255 257
256 258 # build template namespace
257 259 ns = dict(
258 260 status_code=status_code,
259 261 status_message=status_message,
260 262 message=message,
261 263 exception=exception,
262 264 )
263 265
264 266 self.set_header('Content-Type', 'text/html')
265 267 # render the template
266 268 try:
267 269 html = self.render_template('%s.html' % status_code, **ns)
268 270 except TemplateNotFound:
269 271 self.log.debug("No template for %d", status_code)
270 272 html = self.render_template('error.html', **ns)
271 273
272 274 self.write(html)
273 275
274 276
275 277
276 278 class Template404(IPythonHandler):
277 279 """Render our 404 template"""
278 280 def prepare(self):
279 281 raise web.HTTPError(404)
280 282
281 283
282 284 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
283 285 """static files should only be accessible when logged in"""
284 286
285 287 @web.authenticated
286 288 def get(self, path):
287 289 if os.path.splitext(path)[1] == '.ipynb':
288 290 name = os.path.basename(path)
289 291 self.set_header('Content-Type', 'application/json')
290 292 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
291 293
292 294 return web.StaticFileHandler.get(self, path)
293 295
294 296 def compute_etag(self):
295 297 return None
296 298
297 299 def validate_absolute_path(self, root, absolute_path):
298 300 """Validate and return the absolute path.
299 301
300 302 Requires tornado 3.1
301 303
302 304 Adding to tornado's own handling, forbids the serving of hidden files.
303 305 """
304 306 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
305 307 abs_root = os.path.abspath(root)
306 308 if is_hidden(abs_path, abs_root):
307 309 self.log.info("Refusing to serve hidden file, via 404 Error")
308 310 raise web.HTTPError(404)
309 311 return abs_path
310 312
311 313
312 314 def json_errors(method):
313 315 """Decorate methods with this to return GitHub style JSON errors.
314 316
315 317 This should be used on any JSON API on any handler method that can raise HTTPErrors.
316 318
317 319 This will grab the latest HTTPError exception using sys.exc_info
318 320 and then:
319 321
320 322 1. Set the HTTP status code based on the HTTPError
321 323 2. Create and return a JSON body with a message field describing
322 324 the error in a human readable form.
323 325 """
324 326 @functools.wraps(method)
325 327 def wrapper(self, *args, **kwargs):
326 328 try:
327 329 result = method(self, *args, **kwargs)
328 330 except web.HTTPError as e:
329 331 status = e.status_code
330 332 message = e.log_message
331 333 self.log.warn(message)
332 334 self.set_status(e.status_code)
333 335 self.finish(json.dumps(dict(message=message)))
334 336 except Exception:
335 337 self.log.error("Unhandled error in API request", exc_info=True)
336 338 status = 500
337 339 message = "Unknown server error"
338 340 t, value, tb = sys.exc_info()
339 341 self.set_status(status)
340 342 tb_text = ''.join(traceback.format_exception(t, value, tb))
341 343 reply = dict(message=message, traceback=tb_text)
342 344 self.finish(json.dumps(reply))
343 345 else:
344 346 return result
345 347 return wrapper
346 348
347 349
348 350
349 351 #-----------------------------------------------------------------------------
350 352 # File handler
351 353 #-----------------------------------------------------------------------------
352 354
353 355 # to minimize subclass changes:
354 356 HTTPError = web.HTTPError
355 357
356 358 class FileFindHandler(web.StaticFileHandler):
357 359 """subclass of StaticFileHandler for serving files from a search path"""
358 360
359 361 # cache search results, don't search for files more than once
360 362 _static_paths = {}
361 363
362 364 def initialize(self, path, default_filename=None):
363 365 if isinstance(path, string_types):
364 366 path = [path]
365 367
366 368 self.root = tuple(
367 369 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
368 370 )
369 371 self.default_filename = default_filename
370 372
371 373 def compute_etag(self):
372 374 return None
373 375
374 376 @classmethod
375 377 def get_absolute_path(cls, roots, path):
376 378 """locate a file to serve on our static file search path"""
377 379 with cls._lock:
378 380 if path in cls._static_paths:
379 381 return cls._static_paths[path]
380 382 try:
381 383 abspath = os.path.abspath(filefind(path, roots))
382 384 except IOError:
383 385 # IOError means not found
384 386 return ''
385 387
386 388 cls._static_paths[path] = abspath
387 389 return abspath
388 390
389 391 def validate_absolute_path(self, root, absolute_path):
390 392 """check if the file should be served (raises 404, 403, etc.)"""
391 393 if absolute_path == '':
392 394 raise web.HTTPError(404)
393 395
394 396 for root in self.root:
395 397 if (absolute_path + os.sep).startswith(root):
396 398 break
397 399
398 400 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
399 401
400 402
403 class ApiVersionHandler(IPythonHandler):
404
405 @json_errors
406 def get(self):
407 # not authenticated, so give as few info as possible
408 self.finish(json.dumps({"version":IPython.__version__}))
409
401 410 class TrailingSlashHandler(web.RequestHandler):
402 411 """Simple redirect handler that strips trailing slashes
403 412
404 413 This should be the first, highest priority handler.
405 414 """
406 415
407 416 SUPPORTED_METHODS = ['GET']
408 417
409 418 def get(self):
410 419 self.redirect(self.request.uri.rstrip('/'))
411 420
412 421
413 422 class FilesRedirectHandler(IPythonHandler):
414 423 """Handler for redirecting relative URLs to the /files/ handler"""
415 424 def get(self, path=''):
416 425 cm = self.contents_manager
417 426 if cm.path_exists(path):
418 427 # it's a *directory*, redirect to /tree
419 428 url = url_path_join(self.base_url, 'tree', path)
420 429 else:
421 430 orig_path = path
422 431 # otherwise, redirect to /files
423 432 parts = path.split('/')
424 433 path = '/'.join(parts[:-1])
425 434 name = parts[-1]
426 435
427 436 if not cm.file_exists(name=name, path=path) and 'files' in parts:
428 437 # redirect without files/ iff it would 404
429 438 # this preserves pre-2.0-style 'files/' links
430 439 self.log.warn("Deprecated files/ URL: %s", orig_path)
431 440 parts.remove('files')
432 441 path = '/'.join(parts[:-1])
433 442
434 443 if not cm.file_exists(name=name, path=path):
435 444 raise web.HTTPError(404)
436 445
437 446 url = url_path_join(self.base_url, 'files', path, name)
438 447 url = url_escape(url)
439 448 self.log.debug("Redirecting %s to %s", self.request.path, url)
440 449 self.redirect(url)
441 450
442 451
443 452 #-----------------------------------------------------------------------------
444 453 # URL pattern fragments for re-use
445 454 #-----------------------------------------------------------------------------
446 455
447 456 path_regex = r"(?P<path>(?:/.*)*)"
448 457 notebook_name_regex = r"(?P<name>[^/]+\.ipynb)"
449 458 notebook_path_regex = "%s/%s" % (path_regex, notebook_name_regex)
450 459 file_name_regex = r"(?P<name>[^/]+)"
451 460 file_path_regex = "%s/%s" % (path_regex, file_name_regex)
452 461
453 462 #-----------------------------------------------------------------------------
454 463 # URL to handler mappings
455 464 #-----------------------------------------------------------------------------
456 465
457 466
458 467 default_handlers = [
459 (r".*/", TrailingSlashHandler)
468 (r".*/", TrailingSlashHandler),
469 (r"api", ApiVersionHandler)
460 470 ]
General Comments 0
You need to be logged in to leave comments. Login now