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