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 30 31 """Dispatch WebSocket request. 32 """ 33 34 35 import logging 36 import os 37 import re 38 39 from mod_pywebsocket import common 40 from mod_pywebsocket import handshake 41 from mod_pywebsocket import msgutil 42 from mod_pywebsocket import mux 43 from mod_pywebsocket import stream 44 from mod_pywebsocket import util 45 46 47 _SOURCE_PATH_PATTERN = re.compile(r'(?i)_wsh\.py$') 48 _SOURCE_SUFFIX = '_wsh.py' 49 _DO_EXTRA_HANDSHAKE_HANDLER_NAME = 'web_socket_do_extra_handshake' 50 _TRANSFER_DATA_HANDLER_NAME = 'web_socket_transfer_data' 51 _PASSIVE_CLOSING_HANDSHAKE_HANDLER_NAME = ( 52 'web_socket_passive_closing_handshake') 53 54 55 class DispatchException(Exception): 56 """Exception in dispatching WebSocket request.""" 57 58 def __init__(self, name, status=common.HTTP_STATUS_NOT_FOUND): 59 super(DispatchException, self).__init__(name) 60 self.status = status 61 62 63 def _default_passive_closing_handshake_handler(request): 64 """Default web_socket_passive_closing_handshake handler.""" 65 66 return common.STATUS_NORMAL_CLOSURE, '' 67 68 69 def _normalize_path(path): 70 """Normalize path. 71 72 Args: 73 path: the path to normalize. 74 75 Path is converted to the absolute path. 76 The input path can use either '\\' or '/' as the separator. 77 The normalized path always uses '/' regardless of the platform. 78 """ 79 80 path = path.replace('\\', os.path.sep) 81 path = os.path.realpath(path) 82 path = path.replace('\\', '/') 83 return path 84 85 86 def _create_path_to_resource_converter(base_dir): 87 """Returns a function that converts the path of a WebSocket handler source 88 file to a resource string by removing the path to the base directory from 89 its head, removing _SOURCE_SUFFIX from its tail, and replacing path 90 separators in it with '/'. 91 92 Args: 93 base_dir: the path to the base directory. 94 """ 95 96 base_dir = _normalize_path(base_dir) 97 98 base_len = len(base_dir) 99 suffix_len = len(_SOURCE_SUFFIX) 100 101 def converter(path): 102 if not path.endswith(_SOURCE_SUFFIX): 103 return None 104 # _normalize_path must not be used because resolving symlink breaks 105 # following path check. 106 path = path.replace('\\', '/') 107 if not path.startswith(base_dir): 108 return None 109 return path[base_len:-suffix_len] 110 111 return converter 112 113 114 def _enumerate_handler_file_paths(directory): 115 """Returns a generator that enumerates WebSocket Handler source file names 116 in the given directory. 117 """ 118 119 for root, unused_dirs, files in os.walk(directory): 120 for base in files: 121 path = os.path.join(root, base) 122 if _SOURCE_PATH_PATTERN.search(path): 123 yield path 124 125 126 class _HandlerSuite(object): 127 """A handler suite holder class.""" 128 129 def __init__(self, do_extra_handshake, transfer_data, 130 passive_closing_handshake): 131 self.do_extra_handshake = do_extra_handshake 132 self.transfer_data = transfer_data 133 self.passive_closing_handshake = passive_closing_handshake 134 135 136 def _source_handler_file(handler_definition): 137 """Source a handler definition string. 138 139 Args: 140 handler_definition: a string containing Python statements that define 141 handler functions. 142 """ 143 144 global_dic = {} 145 try: 146 exec handler_definition in global_dic 147 except Exception: 148 raise DispatchException('Error in sourcing handler:' + 149 util.get_stack_trace()) 150 passive_closing_handshake_handler = None 151 try: 152 passive_closing_handshake_handler = _extract_handler( 153 global_dic, _PASSIVE_CLOSING_HANDSHAKE_HANDLER_NAME) 154 except Exception: 155 passive_closing_handshake_handler = ( 156 _default_passive_closing_handshake_handler) 157 return _HandlerSuite( 158 _extract_handler(global_dic, _DO_EXTRA_HANDSHAKE_HANDLER_NAME), 159 _extract_handler(global_dic, _TRANSFER_DATA_HANDLER_NAME), 160 passive_closing_handshake_handler) 161 162 163 def _extract_handler(dic, name): 164 """Extracts a callable with the specified name from the given dictionary 165 dic. 166 """ 167 168 if name not in dic: 169 raise DispatchException('%s is not defined.' % name) 170 handler = dic[name] 171 if not callable(handler): 172 raise DispatchException('%s is not callable.' % name) 173 return handler 174 175 176 class Dispatcher(object): 177 """Dispatches WebSocket requests. 178 179 This class maintains a map from resource name to handlers. 180 """ 181 182 def __init__( 183 self, root_dir, scan_dir=None, 184 allow_handlers_outside_root_dir=True): 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( 211 root_dir, scan_dir, allow_handlers_outside_root_dir) 212 213 def add_resource_path_alias(self, 214 alias_resource_path, existing_resource_path): 215 """Add resource path alias. 216 217 Once added, request to alias_resource_path would be handled by 218 handler registered for existing_resource_path. 219 220 Args: 221 alias_resource_path: alias resource path 222 existing_resource_path: existing resource path 223 """ 224 try: 225 handler_suite = self._handler_suite_map[existing_resource_path] 226 self._handler_suite_map[alias_resource_path] = handler_suite 227 except KeyError: 228 raise DispatchException('No handler for: %r' % 229 existing_resource_path) 230 231 def source_warnings(self): 232 """Return warnings in sourcing handlers.""" 233 234 return self._source_warnings 235 236 def do_extra_handshake(self, request): 237 """Do extra checking in WebSocket handshake. 238 239 Select a handler based on request.uri and call its 240 web_socket_do_extra_handshake function. 241 242 Args: 243 request: mod_python request. 244 245 Raises: 246 DispatchException: when handler was not found 247 AbortedByUserException: when user handler abort connection 248 HandshakeException: when opening handshake failed 249 """ 250 251 handler_suite = self.get_handler_suite(request.ws_resource) 252 if handler_suite is None: 253 raise DispatchException('No handler for: %r' % request.ws_resource) 254 do_extra_handshake_ = handler_suite.do_extra_handshake 255 try: 256 do_extra_handshake_(request) 257 except handshake.AbortedByUserException, e: 258 raise 259 except Exception, e: 260 util.prepend_message_to_exception( 261 '%s raised exception for %s: ' % ( 262 _DO_EXTRA_HANDSHAKE_HANDLER_NAME, 263 request.ws_resource), 264 e) 265 raise handshake.HandshakeException(e, common.HTTP_STATUS_FORBIDDEN) 266 267 def transfer_data(self, request): 268 """Let a handler transfer_data with a WebSocket client. 269 270 Select a handler based on request.ws_resource and call its 271 web_socket_transfer_data function. 272 273 Args: 274 request: mod_python request. 275 276 Raises: 277 DispatchException: when handler was not found 278 AbortedByUserException: when user handler abort connection 279 """ 280 281 # TODO(tyoshino): Terminate underlying TCP connection if possible. 282 try: 283 if mux.use_mux(request): 284 mux.start(request, self) 285 else: 286 handler_suite = self.get_handler_suite(request.ws_resource) 287 if handler_suite is None: 288 raise DispatchException('No handler for: %r' % 289 request.ws_resource) 290 transfer_data_ = handler_suite.transfer_data 291 transfer_data_(request) 292 293 if not request.server_terminated: 294 request.ws_stream.close_connection() 295 # Catch non-critical exceptions the handler didn't handle. 296 except handshake.AbortedByUserException, e: 297 self._logger.debug('%s', e) 298 raise 299 except msgutil.BadOperationException, e: 300 self._logger.debug('%s', e) 301 request.ws_stream.close_connection(common.STATUS_ABNORMAL_CLOSURE) 302 except msgutil.InvalidFrameException, 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, e: 308 self._logger.debug('%s', e) 309 request.ws_stream.close_connection(common.STATUS_UNSUPPORTED_DATA) 310 except stream.InvalidUTF8Exception, e: 311 self._logger.debug('%s', e) 312 request.ws_stream.close_connection( 313 common.STATUS_INVALID_FRAME_PAYLOAD_DATA) 314 except msgutil.ConnectionTerminatedException, e: 315 self._logger.debug('%s', e) 316 except Exception, e: 317 util.prepend_message_to_exception( 318 '%s raised exception for %s: ' % ( 319 _TRANSFER_DATA_HANDLER_NAME, request.ws_resource), 320 e) 321 raise 322 323 def passive_closing_handshake(self, request): 324 """Prepare code and reason for responding client initiated closing 325 handshake. 326 """ 327 328 handler_suite = self.get_handler_suite(request.ws_resource) 329 if handler_suite is None: 330 return _default_passive_closing_handshake_handler(request) 331 return handler_suite.passive_closing_handshake(request) 332 333 def get_handler_suite(self, resource): 334 """Retrieves two handlers (one for extra handshake processing, and one 335 for data transfer) for the given request as a HandlerSuite object. 336 """ 337 338 fragment = None 339 if '#' in resource: 340 resource, fragment = resource.split('#', 1) 341 if '?' in resource: 342 resource = resource.split('?', 1)[0] 343 handler_suite = self._handler_suite_map.get(resource) 344 if handler_suite and fragment: 345 raise DispatchException('Fragment identifiers MUST NOT be used on ' 346 'WebSocket URIs', 347 common.HTTP_STATUS_BAD_REQUEST) 348 return handler_suite 349 350 def _source_handler_files_in_dir( 351 self, root_dir, scan_dir, allow_handlers_outside_root_dir): 352 """Source all the handler source files in the scan_dir directory. 353 354 The resource path is determined relative to root_dir. 355 """ 356 357 # We build a map from resource to handler code assuming that there's 358 # only one path from root_dir to scan_dir and it can be obtained by 359 # comparing realpath of them. 360 361 # Here we cannot use abspath. See 362 # https://bugs.webkit.org/show_bug.cgi?id=31603 363 364 convert = _create_path_to_resource_converter(root_dir) 365 scan_realpath = os.path.realpath(scan_dir) 366 root_realpath = os.path.realpath(root_dir) 367 for path in _enumerate_handler_file_paths(scan_realpath): 368 if (not allow_handlers_outside_root_dir and 369 (not os.path.realpath(path).startswith(root_realpath))): 370 self._logger.debug( 371 'Canonical path of %s is not under root directory' % 372 path) 373 continue 374 try: 375 handler_suite = _source_handler_file(open(path).read()) 376 except DispatchException, e: 377 self._source_warnings.append('%s: %s' % (path, e)) 378 continue 379 resource = convert(path) 380 if resource is None: 381 self._logger.debug( 382 'Path to resource conversion on %s failed' % path) 383 else: 384 self._handler_suite_map[convert(path)] = handler_suite 385 386 387 # vi:sts=4 sw=4 et 388