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