dispatch.py (15102B)
1 # Copyright 2012, Google Inc. 2 # All rights reserved. 3 # 4 # Redistribution and use in source and binary forms, with or without 5 # modification, are permitted provided that the following conditions are 6 # met: 7 # 8 # * Redistributions of source code must retain the above copyright 9 # notice, this list of conditions and the following disclaimer. 10 # * Redistributions in binary form must reproduce the above 11 # copyright notice, this list of conditions and the following disclaimer 12 # in the documentation and/or other materials provided with the 13 # distribution. 14 # * Neither the name of Google Inc. nor the names of its 15 # contributors may be used to endorse or promote products derived from 16 # this software without specific prior written permission. 17 # 18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 """Dispatch WebSocket request. 30 """ 31 32 from __future__ import absolute_import 33 import io 34 import logging 35 import os 36 import re 37 import traceback 38 39 from mod_pywebsocket import common 40 from mod_pywebsocket import handshake 41 from mod_pywebsocket import msgutil 42 from mod_pywebsocket import stream 43 from mod_pywebsocket import util 44 45 _SOURCE_PATH_PATTERN = re.compile(r'(?i)_wsh\.py$') 46 _SOURCE_SUFFIX = '_wsh.py' 47 _DO_EXTRA_HANDSHAKE_HANDLER_NAME = 'web_socket_do_extra_handshake' 48 _TRANSFER_DATA_HANDLER_NAME = 'web_socket_transfer_data' 49 _PASSIVE_CLOSING_HANDSHAKE_HANDLER_NAME = ( 50 'web_socket_passive_closing_handshake') 51 52 53 class DispatchException(Exception): 54 """Exception in dispatching WebSocket request.""" 55 def __init__(self, name, status=common.HTTP_STATUS_NOT_FOUND): 56 super(DispatchException, self).__init__(name) 57 self.status = status 58 59 60 def _default_passive_closing_handshake_handler(request): 61 """Default web_socket_passive_closing_handshake handler.""" 62 63 return common.STATUS_NORMAL_CLOSURE, '' 64 65 66 def _normalize_path(path): 67 """Normalize path. 68 69 Args: 70 path: the path to normalize. 71 72 Path is converted to the absolute path. 73 The input path can use either '\\' or '/' as the separator. 74 The normalized path always uses '/' regardless of the platform. 75 """ 76 77 path = path.replace('\\', os.path.sep) 78 path = os.path.realpath(path) 79 path = path.replace('\\', '/') 80 return path 81 82 83 def _create_path_to_resource_converter(base_dir): 84 """Returns a function that converts the path of a WebSocket handler source 85 file to a resource string by removing the path to the base directory from 86 its head, removing _SOURCE_SUFFIX from its tail, and replacing path 87 separators in it with '/'. 88 89 Args: 90 base_dir: the path to the base directory. 91 """ 92 93 base_dir = _normalize_path(base_dir) 94 95 base_len = len(base_dir) 96 suffix_len = len(_SOURCE_SUFFIX) 97 98 def converter(path): 99 if not path.endswith(_SOURCE_SUFFIX): 100 return None 101 # _normalize_path must not be used because resolving symlink breaks 102 # following path check. 103 path = path.replace('\\', '/') 104 if not path.startswith(base_dir): 105 return None 106 return path[base_len:-suffix_len] 107 108 return converter 109 110 111 def _enumerate_handler_file_paths(directory): 112 """Returns a generator that enumerates WebSocket Handler source file names 113 in the given directory. 114 """ 115 116 for root, unused_dirs, files in os.walk(directory): 117 for base in files: 118 path = os.path.join(root, base) 119 if _SOURCE_PATH_PATTERN.search(path): 120 yield path 121 122 123 class _HandlerSuite(object): 124 """A handler suite holder class.""" 125 def __init__(self, do_extra_handshake, transfer_data, 126 passive_closing_handshake): 127 self.do_extra_handshake = do_extra_handshake 128 self.transfer_data = transfer_data 129 self.passive_closing_handshake = passive_closing_handshake 130 131 132 def _source_handler_file(handler_definition): 133 """Source a handler definition string. 134 135 Args: 136 handler_definition: a string containing Python statements that define 137 handler functions. 138 """ 139 140 global_dic = {} 141 try: 142 # This statement is gramatically different in python 2 and 3. 143 # Hence, yapf will complain about this. To overcome this, we disable 144 # yapf for this line. 145 exec(handler_definition, global_dic) # yapf: disable 146 except Exception: 147 raise DispatchException('Error in sourcing handler:' + 148 traceback.format_exc()) 149 passive_closing_handshake_handler = None 150 try: 151 passive_closing_handshake_handler = _extract_handler( 152 global_dic, _PASSIVE_CLOSING_HANDSHAKE_HANDLER_NAME) 153 except Exception: 154 passive_closing_handshake_handler = ( 155 _default_passive_closing_handshake_handler) 156 return _HandlerSuite( 157 _extract_handler(global_dic, _DO_EXTRA_HANDSHAKE_HANDLER_NAME), 158 _extract_handler(global_dic, _TRANSFER_DATA_HANDLER_NAME), 159 passive_closing_handshake_handler) 160 161 162 def _extract_handler(dic, name): 163 """Extracts a callable with the specified name from the given dictionary 164 dic. 165 """ 166 167 if name not in dic: 168 raise DispatchException('%s is not defined.' % name) 169 handler = dic[name] 170 if not callable(handler): 171 raise DispatchException('%s is not callable.' % name) 172 return handler 173 174 175 class Dispatcher(object): 176 """Dispatches WebSocket requests. 177 178 This class maintains a map from resource name to handlers. 179 """ 180 def __init__(self, 181 root_dir, 182 scan_dir=None, 183 allow_handlers_outside_root_dir=True, 184 handler_encoding=None): 185 """Construct an instance. 186 187 Args: 188 root_dir: The directory where handler definition files are 189 placed. 190 scan_dir: The directory where handler definition files are 191 searched. scan_dir must be a directory under root_dir, 192 including root_dir itself. If scan_dir is None, 193 root_dir is used as scan_dir. scan_dir can be useful 194 in saving scan time when root_dir contains many 195 subdirectories. 196 allow_handlers_outside_root_dir: Scans handler files even if their 197 canonical path is not under root_dir. 198 """ 199 200 self._logger = util.get_class_logger(self) 201 202 self._handler_suite_map = {} 203 self._source_warnings = [] 204 if scan_dir is None: 205 scan_dir = root_dir 206 if not os.path.realpath(scan_dir).startswith( 207 os.path.realpath(root_dir)): 208 raise DispatchException('scan_dir:%s must be a directory under ' 209 'root_dir:%s.' % (scan_dir, root_dir)) 210 self._source_handler_files_in_dir(root_dir, scan_dir, 211 allow_handlers_outside_root_dir, 212 handler_encoding) 213 214 def add_resource_path_alias(self, alias_resource_path, 215 existing_resource_path): 216 """Add resource path alias. 217 218 Once added, request to alias_resource_path would be handled by 219 handler registered for existing_resource_path. 220 221 Args: 222 alias_resource_path: alias resource path 223 existing_resource_path: existing resource path 224 """ 225 try: 226 handler_suite = self._handler_suite_map[existing_resource_path] 227 self._handler_suite_map[alias_resource_path] = handler_suite 228 except KeyError: 229 raise DispatchException('No handler for: %r' % 230 existing_resource_path) 231 232 def source_warnings(self): 233 """Return warnings in sourcing handlers.""" 234 235 return self._source_warnings 236 237 def do_extra_handshake(self, request): 238 """Do extra checking in WebSocket handshake. 239 240 Select a handler based on request.uri and call its 241 web_socket_do_extra_handshake function. 242 243 Args: 244 request: mod_python request. 245 246 Raises: 247 DispatchException: when handler was not found 248 AbortedByUserException: when user handler abort connection 249 HandshakeException: when opening handshake failed 250 """ 251 252 handler_suite = self.get_handler_suite(request.ws_resource) 253 if handler_suite is None: 254 raise DispatchException('No handler for: %r' % request.ws_resource) 255 do_extra_handshake_ = handler_suite.do_extra_handshake 256 try: 257 do_extra_handshake_(request) 258 except handshake.AbortedByUserException as e: 259 # Re-raise to tell the caller of this function to finish this 260 # connection without sending any error. 261 self._logger.debug('%s', traceback.format_exc()) 262 raise 263 except Exception as e: 264 util.prepend_message_to_exception( 265 '%s raised exception for %s: ' % 266 (_DO_EXTRA_HANDSHAKE_HANDLER_NAME, request.ws_resource), e) 267 raise handshake.HandshakeException(e, common.HTTP_STATUS_FORBIDDEN) 268 269 def transfer_data(self, request): 270 """Let a handler transfer_data with a WebSocket client. 271 272 Select a handler based on request.ws_resource and call its 273 web_socket_transfer_data function. 274 275 Args: 276 request: mod_python request. 277 278 Raises: 279 DispatchException: when handler was not found 280 AbortedByUserException: when user handler abort connection 281 """ 282 283 # TODO(tyoshino): Terminate underlying TCP connection if possible. 284 try: 285 handler_suite = self.get_handler_suite(request.ws_resource) 286 if handler_suite is None: 287 raise DispatchException('No handler for: %r' % 288 request.ws_resource) 289 transfer_data_ = handler_suite.transfer_data 290 transfer_data_(request) 291 292 if not request.server_terminated: 293 request.ws_stream.close_connection() 294 # Catch non-critical exceptions the handler didn't handle. 295 except handshake.AbortedByUserException as e: 296 self._logger.debug('%s', traceback.format_exc()) 297 raise 298 except msgutil.BadOperationException as e: 299 self._logger.debug('%s', e) 300 request.ws_stream.close_connection( 301 common.STATUS_INTERNAL_ENDPOINT_ERROR) 302 except msgutil.InvalidFrameException as e: 303 # InvalidFrameException must be caught before 304 # ConnectionTerminatedException that catches InvalidFrameException. 305 self._logger.debug('%s', e) 306 request.ws_stream.close_connection(common.STATUS_PROTOCOL_ERROR) 307 except msgutil.UnsupportedFrameException as e: 308 self._logger.debug('%s', e) 309 request.ws_stream.close_connection(common.STATUS_UNSUPPORTED_DATA) 310 except stream.InvalidUTF8Exception as e: 311 self._logger.debug('%s', e) 312 request.ws_stream.close_connection( 313 common.STATUS_INVALID_FRAME_PAYLOAD_DATA) 314 except msgutil.ConnectionTerminatedException as e: 315 self._logger.debug('%s', e) 316 except Exception as e: 317 # Any other exceptions are forwarded to the caller of this 318 # function. 319 util.prepend_message_to_exception( 320 '%s raised exception for %s: ' % 321 (_TRANSFER_DATA_HANDLER_NAME, request.ws_resource), e) 322 raise 323 324 def passive_closing_handshake(self, request): 325 """Prepare code and reason for responding client initiated closing 326 handshake. 327 """ 328 329 handler_suite = self.get_handler_suite(request.ws_resource) 330 if handler_suite is None: 331 return _default_passive_closing_handshake_handler(request) 332 return handler_suite.passive_closing_handshake(request) 333 334 def get_handler_suite(self, resource): 335 """Retrieves two handlers (one for extra handshake processing, and one 336 for data transfer) for the given request as a HandlerSuite object. 337 """ 338 339 fragment = None 340 if '#' in resource: 341 resource, fragment = resource.split('#', 1) 342 if '?' in resource: 343 resource = resource.split('?', 1)[0] 344 handler_suite = self._handler_suite_map.get(resource) 345 if handler_suite and fragment: 346 raise DispatchException( 347 'Fragment identifiers MUST NOT be used on WebSocket URIs', 348 common.HTTP_STATUS_BAD_REQUEST) 349 return handler_suite 350 351 def _source_handler_files_in_dir(self, root_dir, scan_dir, 352 allow_handlers_outside_root_dir, 353 handler_encoding): 354 """Source all the handler source files in the scan_dir directory. 355 356 The resource path is determined relative to root_dir. 357 """ 358 359 # We build a map from resource to handler code assuming that there's 360 # only one path from root_dir to scan_dir and it can be obtained by 361 # comparing realpath of them. 362 363 # Here we cannot use abspath. See 364 # https://bugs.webkit.org/show_bug.cgi?id=31603 365 366 convert = _create_path_to_resource_converter(root_dir) 367 scan_realpath = os.path.realpath(scan_dir) 368 root_realpath = os.path.realpath(root_dir) 369 for path in _enumerate_handler_file_paths(scan_realpath): 370 if (not allow_handlers_outside_root_dir and 371 (not os.path.realpath(path).startswith(root_realpath))): 372 self._logger.debug( 373 'Canonical path of %s is not under root directory' % path) 374 continue 375 try: 376 with io.open(path, encoding=handler_encoding) as handler_file: 377 handler_suite = _source_handler_file(handler_file.read()) 378 except DispatchException as e: 379 self._source_warnings.append('%s: %s' % (path, e)) 380 continue 381 resource = convert(path) 382 if resource is None: 383 self._logger.debug('Path to resource conversion on %s failed' % 384 path) 385 else: 386 self._handler_suite_map[convert(path)] = handler_suite 387 388 389 # vi:sts=4 sw=4 et