##// END OF EJS Templates
tweak stat walk in forbid_hidden
MinRK -
Show More
@@ -1,529 +1,529 b''
1 1 """Base Tornado handlers for the notebook.
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19
20 20 import datetime
21 21 import email.utils
22 22 import functools
23 23 import hashlib
24 24 import json
25 25 import logging
26 26 import mimetypes
27 27 import os
28 28 import stat
29 29 import sys
30 30 import threading
31 31 import traceback
32 32
33 33 from tornado import web
34 34 from tornado import websocket
35 35
36 36 try:
37 37 from tornado.log import app_log
38 38 except ImportError:
39 39 app_log = logging.getLogger()
40 40
41 41 from IPython.config import Application
42 42 from IPython.external.decorator import decorator
43 43 from IPython.utils.path import filefind
44 44 from IPython.utils.jsonutil import date_default
45 45
46 46 # UF_HIDDEN is a stat flag not defined in the stat module.
47 47 # It is used by BSD to indicate hidden files.
48 48 UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768)
49 49
50 50 #-----------------------------------------------------------------------------
51 51 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
52 52 #-----------------------------------------------------------------------------
53 53
54 54 # Google Chrome, as of release 16, changed its websocket protocol number. The
55 55 # parts tornado cares about haven't really changed, so it's OK to continue
56 56 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
57 57 # version as of Oct 30/2011) the version check fails, see the issue report:
58 58
59 59 # https://github.com/facebook/tornado/issues/385
60 60
61 61 # This issue has been fixed in Tornado post 2.1.1:
62 62
63 63 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
64 64
65 65 # Here we manually apply the same patch as above so that users of IPython can
66 66 # continue to work with an officially released Tornado. We make the
67 67 # monkeypatch version check as narrow as possible to limit its effects; once
68 68 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
69 69
70 70 import tornado
71 71
72 72 if tornado.version_info <= (2,1,1):
73 73
74 74 def _execute(self, transforms, *args, **kwargs):
75 75 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
76 76
77 77 self.open_args = args
78 78 self.open_kwargs = kwargs
79 79
80 80 # The difference between version 8 and 13 is that in 8 the
81 81 # client sends a "Sec-Websocket-Origin" header and in 13 it's
82 82 # simply "Origin".
83 83 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
84 84 self.ws_connection = WebSocketProtocol8(self)
85 85 self.ws_connection.accept_connection()
86 86
87 87 elif self.request.headers.get("Sec-WebSocket-Version"):
88 88 self.stream.write(tornado.escape.utf8(
89 89 "HTTP/1.1 426 Upgrade Required\r\n"
90 90 "Sec-WebSocket-Version: 8\r\n\r\n"))
91 91 self.stream.close()
92 92
93 93 else:
94 94 self.ws_connection = WebSocketProtocol76(self)
95 95 self.ws_connection.accept_connection()
96 96
97 97 websocket.WebSocketHandler._execute = _execute
98 98 del _execute
99 99
100 100
101 101 #-----------------------------------------------------------------------------
102 102 # Top-level handlers
103 103 #-----------------------------------------------------------------------------
104 104
105 105 class RequestHandler(web.RequestHandler):
106 106 """RequestHandler with default variable setting."""
107 107
108 108 def render(*args, **kwargs):
109 109 kwargs.setdefault('message', '')
110 110 return web.RequestHandler.render(*args, **kwargs)
111 111
112 112 class AuthenticatedHandler(RequestHandler):
113 113 """A RequestHandler with an authenticated user."""
114 114
115 115 def clear_login_cookie(self):
116 116 self.clear_cookie(self.cookie_name)
117 117
118 118 def get_current_user(self):
119 119 user_id = self.get_secure_cookie(self.cookie_name)
120 120 # For now the user_id should not return empty, but it could eventually
121 121 if user_id == '':
122 122 user_id = 'anonymous'
123 123 if user_id is None:
124 124 # prevent extra Invalid cookie sig warnings:
125 125 self.clear_login_cookie()
126 126 if not self.login_available:
127 127 user_id = 'anonymous'
128 128 return user_id
129 129
130 130 @property
131 131 def cookie_name(self):
132 132 default_cookie_name = 'username-{host}'.format(
133 133 host=self.request.host,
134 134 ).replace(':', '-')
135 135 return self.settings.get('cookie_name', default_cookie_name)
136 136
137 137 @property
138 138 def password(self):
139 139 """our password"""
140 140 return self.settings.get('password', '')
141 141
142 142 @property
143 143 def logged_in(self):
144 144 """Is a user currently logged in?
145 145
146 146 """
147 147 user = self.get_current_user()
148 148 return (user and not user == 'anonymous')
149 149
150 150 @property
151 151 def login_available(self):
152 152 """May a user proceed to log in?
153 153
154 154 This returns True if login capability is available, irrespective of
155 155 whether the user is already logged in or not.
156 156
157 157 """
158 158 return bool(self.settings.get('password', ''))
159 159
160 160
161 161 class IPythonHandler(AuthenticatedHandler):
162 162 """IPython-specific extensions to authenticated handling
163 163
164 164 Mostly property shortcuts to IPython-specific settings.
165 165 """
166 166
167 167 @property
168 168 def config(self):
169 169 return self.settings.get('config', None)
170 170
171 171 @property
172 172 def log(self):
173 173 """use the IPython log by default, falling back on tornado's logger"""
174 174 if Application.initialized():
175 175 return Application.instance().log
176 176 else:
177 177 return app_log
178 178
179 179 @property
180 180 def use_less(self):
181 181 """Use less instead of css in templates"""
182 182 return self.settings.get('use_less', False)
183 183
184 184 #---------------------------------------------------------------
185 185 # URLs
186 186 #---------------------------------------------------------------
187 187
188 188 @property
189 189 def ws_url(self):
190 190 """websocket url matching the current request
191 191
192 192 By default, this is just `''`, indicating that it should match
193 193 the same host, protocol, port, etc.
194 194 """
195 195 return self.settings.get('websocket_url', '')
196 196
197 197 @property
198 198 def mathjax_url(self):
199 199 return self.settings.get('mathjax_url', '')
200 200
201 201 @property
202 202 def base_project_url(self):
203 203 return self.settings.get('base_project_url', '/')
204 204
205 205 @property
206 206 def base_kernel_url(self):
207 207 return self.settings.get('base_kernel_url', '/')
208 208
209 209 #---------------------------------------------------------------
210 210 # Manager objects
211 211 #---------------------------------------------------------------
212 212
213 213 @property
214 214 def kernel_manager(self):
215 215 return self.settings['kernel_manager']
216 216
217 217 @property
218 218 def notebook_manager(self):
219 219 return self.settings['notebook_manager']
220 220
221 221 @property
222 222 def cluster_manager(self):
223 223 return self.settings['cluster_manager']
224 224
225 225 @property
226 226 def session_manager(self):
227 227 return self.settings['session_manager']
228 228
229 229 @property
230 230 def project_dir(self):
231 231 return self.notebook_manager.notebook_dir
232 232
233 233 #---------------------------------------------------------------
234 234 # template rendering
235 235 #---------------------------------------------------------------
236 236
237 237 def get_template(self, name):
238 238 """Return the jinja template object for a given name"""
239 239 return self.settings['jinja2_env'].get_template(name)
240 240
241 241 def render_template(self, name, **ns):
242 242 ns.update(self.template_namespace)
243 243 template = self.get_template(name)
244 244 return template.render(**ns)
245 245
246 246 @property
247 247 def template_namespace(self):
248 248 return dict(
249 249 base_project_url=self.base_project_url,
250 250 base_kernel_url=self.base_kernel_url,
251 251 logged_in=self.logged_in,
252 252 login_available=self.login_available,
253 253 use_less=self.use_less,
254 254 )
255 255
256 256 def get_json_body(self):
257 257 """Return the body of the request as JSON data."""
258 258 if not self.request.body:
259 259 return None
260 260 # Do we need to call body.decode('utf-8') here?
261 261 body = self.request.body.strip().decode(u'utf-8')
262 262 try:
263 263 model = json.loads(body)
264 264 except Exception:
265 265 self.log.debug("Bad JSON: %r", body)
266 266 self.log.error("Couldn't parse JSON", exc_info=True)
267 267 raise web.HTTPError(400, u'Invalid JSON in body of request')
268 268 return model
269 269
270 270
271 271 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
272 272 """static files should only be accessible when logged in"""
273 273
274 274 @web.authenticated
275 275 def get(self, path):
276 276 if os.path.splitext(path)[1] == '.ipynb':
277 277 name = os.path.basename(path)
278 278 self.set_header('Content-Type', 'application/json')
279 279 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
280 280
281 281 return web.StaticFileHandler.get(self, path)
282 282
283 283 def validate_absolute_path(self, root, absolute_path):
284 284 """Validate and return the absolute path.
285 285
286 286 Requires tornado 3.1
287 287
288 288 Adding to tornado's own handling, forbids the serving of hidden files.
289 289 """
290 290 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
291 291 abs_root = os.path.abspath(root)
292 292 self.forbid_hidden(abs_root, abs_path)
293 293 return abs_path
294 294
295 295 def forbid_hidden(self, absolute_root, absolute_path):
296 296 """Raise 403 if a file is hidden or contained in a hidden directory.
297 297
298 298 Hidden is determined by either name starting with '.'
299 299 or the UF_HIDDEN flag as reported by stat
300 300 """
301 301 inside_root = absolute_path[len(absolute_root):]
302 302 if any(part.startswith('.') for part in inside_root.split(os.sep)):
303 303 raise web.HTTPError(403)
304 304
305 305 # check UF_HIDDEN on any location up to root
306 306 path = absolute_path
307 while path and path.startswith(absolute_root):
307 while path and path.startswith(absolute_root) and path != absolute_root:
308 308 st = os.stat(path)
309 309 if getattr(st, 'st_flags', 0) & UF_HIDDEN:
310 310 raise web.HTTPError(403)
311 path, _ = os.path.split(path)
311 path = os.path.dirname(path)
312 312
313 313 return absolute_path
314 314
315 315
316 316 def json_errors(method):
317 317 """Decorate methods with this to return GitHub style JSON errors.
318 318
319 319 This should be used on any JSON API on any handler method that can raise HTTPErrors.
320 320
321 321 This will grab the latest HTTPError exception using sys.exc_info
322 322 and then:
323 323
324 324 1. Set the HTTP status code based on the HTTPError
325 325 2. Create and return a JSON body with a message field describing
326 326 the error in a human readable form.
327 327 """
328 328 @functools.wraps(method)
329 329 def wrapper(self, *args, **kwargs):
330 330 try:
331 331 result = method(self, *args, **kwargs)
332 332 except web.HTTPError as e:
333 333 status = e.status_code
334 334 message = e.log_message
335 335 self.set_status(e.status_code)
336 336 self.finish(json.dumps(dict(message=message)))
337 337 except Exception:
338 338 self.log.error("Unhandled error in API request", exc_info=True)
339 339 status = 500
340 340 message = "Unknown server error"
341 341 t, value, tb = sys.exc_info()
342 342 self.set_status(status)
343 343 tb_text = ''.join(traceback.format_exception(t, value, tb))
344 344 reply = dict(message=message, traceback=tb_text)
345 345 self.finish(json.dumps(reply))
346 346 else:
347 347 return result
348 348 return wrapper
349 349
350 350
351 351
352 352 #-----------------------------------------------------------------------------
353 353 # File handler
354 354 #-----------------------------------------------------------------------------
355 355
356 356 # to minimize subclass changes:
357 357 HTTPError = web.HTTPError
358 358
359 359 class FileFindHandler(web.StaticFileHandler):
360 360 """subclass of StaticFileHandler for serving files from a search path"""
361 361
362 362 _static_paths = {}
363 363 # _lock is needed for tornado < 2.2.0 compat
364 364 _lock = threading.Lock() # protects _static_hashes
365 365
366 366 def initialize(self, path, default_filename=None):
367 367 if isinstance(path, basestring):
368 368 path = [path]
369 369 self.roots = tuple(
370 370 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
371 371 )
372 372 self.default_filename = default_filename
373 373
374 374 @classmethod
375 375 def locate_file(cls, path, roots):
376 376 """locate a file to serve on our static file search path"""
377 377 with cls._lock:
378 378 if path in cls._static_paths:
379 379 return cls._static_paths[path]
380 380 try:
381 381 abspath = os.path.abspath(filefind(path, roots))
382 382 except IOError:
383 383 # empty string should always give exists=False
384 384 return ''
385 385
386 386 # os.path.abspath strips a trailing /
387 387 # it needs to be temporarily added back for requests to root/
388 388 if not (abspath + os.sep).startswith(roots):
389 389 raise HTTPError(403, "%s is not in root static directory", path)
390 390
391 391 cls._static_paths[path] = abspath
392 392 return abspath
393 393
394 394 def get(self, path, include_body=True):
395 395 path = self.parse_url_path(path)
396 396
397 397 # begin subclass override
398 398 abspath = self.locate_file(path, self.roots)
399 399 # end subclass override
400 400
401 401 if os.path.isdir(abspath) and self.default_filename is not None:
402 402 # need to look at the request.path here for when path is empty
403 403 # but there is some prefix to the path that was already
404 404 # trimmed by the routing
405 405 if not self.request.path.endswith("/"):
406 406 self.redirect(self.request.path + "/")
407 407 return
408 408 abspath = os.path.join(abspath, self.default_filename)
409 409 if not os.path.exists(abspath):
410 410 raise HTTPError(404)
411 411 if not os.path.isfile(abspath):
412 412 raise HTTPError(403, "%s is not a file", path)
413 413
414 414 stat_result = os.stat(abspath)
415 415 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
416 416
417 417 self.set_header("Last-Modified", modified)
418 418
419 419 mime_type, encoding = mimetypes.guess_type(abspath)
420 420 if mime_type:
421 421 self.set_header("Content-Type", mime_type)
422 422
423 423 cache_time = self.get_cache_time(path, modified, mime_type)
424 424
425 425 if cache_time > 0:
426 426 self.set_header("Expires", datetime.datetime.utcnow() + \
427 427 datetime.timedelta(seconds=cache_time))
428 428 self.set_header("Cache-Control", "max-age=" + str(cache_time))
429 429 else:
430 430 self.set_header("Cache-Control", "public")
431 431
432 432 self.set_extra_headers(path)
433 433
434 434 # Check the If-Modified-Since, and don't send the result if the
435 435 # content has not been modified
436 436 ims_value = self.request.headers.get("If-Modified-Since")
437 437 if ims_value is not None:
438 438 date_tuple = email.utils.parsedate(ims_value)
439 439 if_since = datetime.datetime(*date_tuple[:6])
440 440 if if_since >= modified:
441 441 self.set_status(304)
442 442 return
443 443
444 444 with open(abspath, "rb") as file:
445 445 data = file.read()
446 446 hasher = hashlib.sha1()
447 447 hasher.update(data)
448 448 self.set_header("Etag", '"%s"' % hasher.hexdigest())
449 449 if include_body:
450 450 self.write(data)
451 451 else:
452 452 assert self.request.method == "HEAD"
453 453 self.set_header("Content-Length", len(data))
454 454
455 455 @classmethod
456 456 def get_version(cls, settings, path):
457 457 """Generate the version string to be used in static URLs.
458 458
459 459 This method may be overridden in subclasses (but note that it
460 460 is a class method rather than a static method). The default
461 461 implementation uses a hash of the file's contents.
462 462
463 463 ``settings`` is the `Application.settings` dictionary and ``path``
464 464 is the relative location of the requested asset on the filesystem.
465 465 The returned value should be a string, or ``None`` if no version
466 466 could be determined.
467 467 """
468 468 # begin subclass override:
469 469 static_paths = settings['static_path']
470 470 if isinstance(static_paths, basestring):
471 471 static_paths = [static_paths]
472 472 roots = tuple(
473 473 os.path.abspath(os.path.expanduser(p)) + os.sep for p in static_paths
474 474 )
475 475
476 476 try:
477 477 abs_path = filefind(path, roots)
478 478 except IOError:
479 479 app_log.error("Could not find static file %r", path)
480 480 return None
481 481
482 482 # end subclass override
483 483
484 484 with cls._lock:
485 485 hashes = cls._static_hashes
486 486 if abs_path not in hashes:
487 487 try:
488 488 f = open(abs_path, "rb")
489 489 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
490 490 f.close()
491 491 except Exception:
492 492 app_log.error("Could not open static file %r", path)
493 493 hashes[abs_path] = None
494 494 hsh = hashes.get(abs_path)
495 495 if hsh:
496 496 return hsh[:5]
497 497 return None
498 498
499 499
500 500 def parse_url_path(self, url_path):
501 501 """Converts a static URL path into a filesystem path.
502 502
503 503 ``url_path`` is the path component of the URL with
504 504 ``static_url_prefix`` removed. The return value should be
505 505 filesystem path relative to ``static_path``.
506 506 """
507 507 if os.sep != "/":
508 508 url_path = url_path.replace("/", os.sep)
509 509 return url_path
510 510
511 511 class TrailingSlashHandler(web.RequestHandler):
512 512 """Simple redirect handler that strips trailing slashes
513 513
514 514 This should be the first, highest priority handler.
515 515 """
516 516
517 517 SUPPORTED_METHODS = ['GET']
518 518
519 519 def get(self):
520 520 self.redirect(self.request.uri.rstrip('/'))
521 521
522 522 #-----------------------------------------------------------------------------
523 523 # URL to handler mappings
524 524 #-----------------------------------------------------------------------------
525 525
526 526
527 527 default_handlers = [
528 528 (r".*/", TrailingSlashHandler)
529 529 ]
General Comments 0
You need to be logged in to leave comments. Login now