1# Contributors Listed Below - COPYRIGHT 2016 2# [+] International Business Machines Corp. 3# 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 14# implied. See the License for the specific language governing 15# permissions and limitations under the License. 16 17import os 18import dbus 19import dbus.exceptions 20import json 21from xml.etree import ElementTree 22from bottle import Bottle, abort, request, response, JSONPlugin, HTTPError 23from bottle import static_file 24import obmc.utils.misc 25from obmc.dbuslib.introspection import IntrospectionNodeParser 26import obmc.mapper 27import spwd 28import grp 29import crypt 30import tempfile 31import re 32import mimetypes 33have_wsock = True 34try: 35 from geventwebsocket import WebSocketError 36except ImportError: 37 have_wsock = False 38if have_wsock: 39 from dbus.mainloop.glib import DBusGMainLoop 40 DBusGMainLoop(set_as_default=True) 41 # TODO: openbmc/openbmc#2994 remove python 2 support 42 try: # python 2 43 import gobject 44 except ImportError: # python 3 45 from gi.repository import GObject as gobject 46 import gevent 47 from gevent import socket 48 from gevent import Greenlet 49 50DBUS_UNKNOWN_INTERFACE = 'org.freedesktop.DBus.Error.UnknownInterface' 51DBUS_UNKNOWN_METHOD = 'org.freedesktop.DBus.Error.UnknownMethod' 52DBUS_INVALID_ARGS = 'org.freedesktop.DBus.Error.InvalidArgs' 53DBUS_TYPE_ERROR = 'org.freedesktop.DBus.Python.TypeError' 54DELETE_IFACE = 'xyz.openbmc_project.Object.Delete' 55SOFTWARE_PATH = '/xyz/openbmc_project/software' 56 57_4034_msg = "The specified %s cannot be %s: '%s'" 58 59www_base_path = '/usr/share/www/' 60 61 62def valid_user(session, *a, **kw): 63 ''' Authorization plugin callback that checks 64 that the user is logged in. ''' 65 if session is None: 66 abort(401, 'Login required') 67 68 69def get_type_signature_by_introspection(bus, service, object_path, 70 property_name): 71 obj = bus.get_object(service, object_path) 72 iface = dbus.Interface(obj, 'org.freedesktop.DBus.Introspectable') 73 xml_string = iface.Introspect() 74 for child in ElementTree.fromstring(xml_string): 75 # Iterate over each interfaces's properties to find 76 # matching property_name, and return its signature string 77 if child.tag == 'interface': 78 for i in child.iter(): 79 if ('name' in i.attrib) and \ 80 (i.attrib['name'] == property_name): 81 type_signature = i.attrib['type'] 82 return type_signature 83 84 85def get_method_signature(bus, service, object_path, interface, method): 86 obj = bus.get_object(service, object_path) 87 iface = dbus.Interface(obj, 'org.freedesktop.DBus.Introspectable') 88 xml_string = iface.Introspect() 89 arglist = [] 90 91 root = ElementTree.fromstring(xml_string) 92 for dbus_intf in root.findall('interface'): 93 if (dbus_intf.get('name') == interface): 94 for dbus_method in dbus_intf.findall('method'): 95 if(dbus_method.get('name') == method): 96 for arg in dbus_method.findall('arg'): 97 arglist.append(arg.get('type')) 98 return arglist 99 100 101def split_struct_signature(signature): 102 struct_regex = r'(b|y|n|i|x|q|u|t|d|s|a\(.+?\)|\(.+?\))|a\{.+?\}+?' 103 struct_matches = re.findall(struct_regex, signature) 104 return struct_matches 105 106 107def convert_type(signature, value): 108 # Basic Types 109 converted_value = None 110 converted_container = None 111 # TODO: openbmc/openbmc#2994 remove python 2 support 112 try: # python 2 113 basic_types = {'b': bool, 'y': dbus.Byte, 'n': dbus.Int16, 'i': int, 114 'x': long, 'q': dbus.UInt16, 'u': dbus.UInt32, 115 't': dbus.UInt64, 'd': float, 's': str} 116 except NameError: # python 3 117 basic_types = {'b': bool, 'y': dbus.Byte, 'n': dbus.Int16, 'i': int, 118 'x': int, 'q': dbus.UInt16, 'u': dbus.UInt32, 119 't': dbus.UInt64, 'd': float, 's': str} 120 array_matches = re.match(r'a\((\S+)\)', signature) 121 struct_matches = re.match(r'\((\S+)\)', signature) 122 dictionary_matches = re.match(r'a{(\S+)}', signature) 123 if signature in basic_types: 124 converted_value = basic_types[signature](value) 125 return converted_value 126 # Array 127 if array_matches: 128 element_type = array_matches.group(1) 129 converted_container = list() 130 # Test if value is a list 131 # to avoid iterating over each character in a string. 132 # Iterate over each item and convert type 133 if isinstance(value, list): 134 for i in value: 135 converted_element = convert_type(element_type, i) 136 converted_container.append(converted_element) 137 # Convert non-sequence to expected type, and append to list 138 else: 139 converted_element = convert_type(element_type, value) 140 converted_container.append(converted_element) 141 return converted_container 142 # Struct 143 if struct_matches: 144 element_types = struct_matches.group(1) 145 split_element_types = split_struct_signature(element_types) 146 converted_container = list() 147 # Test if value is a list 148 if isinstance(value, list): 149 for index, val in enumerate(value): 150 converted_element = convert_type(split_element_types[index], 151 value[index]) 152 converted_container.append(converted_element) 153 else: 154 converted_element = convert_type(element_types, value) 155 converted_container.append(converted_element) 156 return tuple(converted_container) 157 # Dictionary 158 if dictionary_matches: 159 element_types = dictionary_matches.group(1) 160 split_element_types = split_struct_signature(element_types) 161 converted_container = dict() 162 # Convert each element of dict 163 for key, val in value.items(): 164 converted_key = convert_type(split_element_types[0], key) 165 converted_val = convert_type(split_element_types[1], val) 166 converted_container[converted_key] = converted_val 167 return converted_container 168 169 170class UserInGroup: 171 ''' Authorization plugin callback that checks that the user is logged in 172 and a member of a group. ''' 173 def __init__(self, group): 174 self.group = group 175 176 def __call__(self, session, *a, **kw): 177 valid_user(session, *a, **kw) 178 res = False 179 180 try: 181 res = session['user'] in grp.getgrnam(self.group)[3] 182 except KeyError: 183 pass 184 185 if not res: 186 abort(403, 'Insufficient access') 187 188 189class RouteHandler(object): 190 _require_auth = obmc.utils.misc.makelist(valid_user) 191 _enable_cors = True 192 193 def __init__(self, app, bus, verbs, rules, content_type=''): 194 self.app = app 195 self.bus = bus 196 self.mapper = obmc.mapper.Mapper(bus) 197 self._verbs = obmc.utils.misc.makelist(verbs) 198 self._rules = rules 199 self._content_type = content_type 200 201 if 'GET' in self._verbs: 202 self._verbs = list(set(self._verbs + ['HEAD'])) 203 if 'OPTIONS' not in self._verbs: 204 self._verbs.append('OPTIONS') 205 206 def _setup(self, **kw): 207 request.route_data = {} 208 209 if request.method in self._verbs: 210 if request.method != 'OPTIONS': 211 return self.setup(**kw) 212 213 # Javascript implementations will not send credentials 214 # with an OPTIONS request. Don't help malicious clients 215 # by checking the path here and returning a 404 if the 216 # path doesn't exist. 217 return None 218 219 # Return 405 220 raise HTTPError( 221 405, "Method not allowed.", Allow=','.join(self._verbs)) 222 223 def __call__(self, **kw): 224 return getattr(self, 'do_' + request.method.lower())(**kw) 225 226 def do_head(self, **kw): 227 return self.do_get(**kw) 228 229 def do_options(self, **kw): 230 for v in self._verbs: 231 response.set_header( 232 'Allow', 233 ','.join(self._verbs)) 234 return None 235 236 def install(self): 237 self.app.route( 238 self._rules, callback=self, 239 method=['OPTIONS', 'GET', 'PUT', 'PATCH', 'POST', 'DELETE']) 240 241 @staticmethod 242 def try_mapper_call(f, callback=None, **kw): 243 try: 244 return f(**kw) 245 except dbus.exceptions.DBusException as e: 246 if e.get_dbus_name() == \ 247 'org.freedesktop.DBus.Error.ObjectPathInUse': 248 abort(503, str(e)) 249 if e.get_dbus_name() != obmc.mapper.MAPPER_NOT_FOUND: 250 raise 251 if callback is None: 252 def callback(e, **kw): 253 abort(404, str(e)) 254 255 callback(e, **kw) 256 257 @staticmethod 258 def try_properties_interface(f, *a): 259 try: 260 return f(*a) 261 except dbus.exceptions.DBusException as e: 262 if DBUS_UNKNOWN_INTERFACE in e.get_dbus_name(): 263 # interface doesn't have any properties 264 return None 265 if DBUS_UNKNOWN_METHOD == e.get_dbus_name(): 266 # properties interface not implemented at all 267 return None 268 raise 269 270 271class DirectoryHandler(RouteHandler): 272 verbs = 'GET' 273 rules = '<path:path>/' 274 275 def __init__(self, app, bus): 276 super(DirectoryHandler, self).__init__( 277 app, bus, self.verbs, self.rules) 278 279 def find(self, path='/'): 280 return self.try_mapper_call( 281 self.mapper.get_subtree_paths, path=path, depth=1) 282 283 def setup(self, path='/'): 284 request.route_data['map'] = self.find(path) 285 286 def do_get(self, path='/'): 287 return request.route_data['map'] 288 289 290class ListNamesHandler(RouteHandler): 291 verbs = 'GET' 292 rules = ['/list', '<path:path>/list'] 293 294 def __init__(self, app, bus): 295 super(ListNamesHandler, self).__init__( 296 app, bus, self.verbs, self.rules) 297 298 def find(self, path='/'): 299 return list(self.try_mapper_call( 300 self.mapper.get_subtree, path=path).keys()) 301 302 def setup(self, path='/'): 303 request.route_data['map'] = self.find(path) 304 305 def do_get(self, path='/'): 306 return request.route_data['map'] 307 308 309class ListHandler(RouteHandler): 310 verbs = 'GET' 311 rules = ['/enumerate', '<path:path>/enumerate'] 312 313 def __init__(self, app, bus): 314 super(ListHandler, self).__init__( 315 app, bus, self.verbs, self.rules) 316 317 def find(self, path='/'): 318 return self.try_mapper_call( 319 self.mapper.get_subtree, path=path) 320 321 def setup(self, path='/'): 322 request.route_data['map'] = self.find(path) 323 324 def do_get(self, path='/'): 325 return {x: y for x, y in self.mapper.enumerate_subtree( 326 path, 327 mapper_data=request.route_data['map']).dataitems()} 328 329 330class MethodHandler(RouteHandler): 331 verbs = 'POST' 332 rules = '<path:path>/action/<method>' 333 request_type = list 334 content_type = 'application/json' 335 336 def __init__(self, app, bus): 337 super(MethodHandler, self).__init__( 338 app, bus, self.verbs, self.rules, self.content_type) 339 self.service = '' 340 self.interface = '' 341 342 def find(self, path, method): 343 method_list = [] 344 buses = self.try_mapper_call( 345 self.mapper.get_object, path=path) 346 for items in buses.items(): 347 m = self.find_method_on_bus(path, method, *items) 348 if m: 349 method_list.append(m) 350 if method_list: 351 return method_list 352 353 abort(404, _4034_msg % ('method', 'found', method)) 354 355 def setup(self, path, method): 356 request.route_data['map'] = self.find(path, method) 357 358 def do_post(self, path, method, retry=True): 359 try: 360 args = [] 361 if request.parameter_list: 362 args = request.parameter_list 363 # To see if the return type is capable of being merged 364 if len(request.route_data['map']) > 1: 365 results = None 366 for item in request.route_data['map']: 367 tmp = item(*args) 368 if not results: 369 if tmp is not None: 370 results = type(tmp)() 371 if isinstance(results, dict): 372 results = results.update(tmp) 373 elif isinstance(results, list): 374 results = results + tmp 375 elif isinstance(results, type(None)): 376 results = None 377 else: 378 abort(501, 'Don\'t know how to merge method call ' 379 'results of {}'.format(type(tmp))) 380 return results 381 # There is only one method 382 return request.route_data['map'][0](*args) 383 384 except dbus.exceptions.DBusException as e: 385 paramlist = [] 386 if e.get_dbus_name() == DBUS_INVALID_ARGS and retry: 387 388 signature_list = get_method_signature(self.bus, self.service, 389 path, self.interface, 390 method) 391 if not signature_list: 392 abort(400, "Failed to get method signature: %s" % str(e)) 393 if len(signature_list) != len(request.parameter_list): 394 abort(400, "Invalid number of args") 395 converted_value = None 396 try: 397 for index, expected_type in enumerate(signature_list): 398 value = request.parameter_list[index] 399 converted_value = convert_type(expected_type, value) 400 paramlist.append(converted_value) 401 request.parameter_list = paramlist 402 self.do_post(path, method, False) 403 return 404 except Exception as ex: 405 abort(400, "Bad Request/Invalid Args given") 406 abort(400, str(e)) 407 408 if e.get_dbus_name() == DBUS_TYPE_ERROR: 409 abort(400, str(e)) 410 raise 411 412 @staticmethod 413 def find_method_in_interface(method, obj, interface, methods): 414 if methods is None: 415 return None 416 417 method = obmc.utils.misc.find_case_insensitive(method, list(methods.keys())) 418 if method is not None: 419 iface = dbus.Interface(obj, interface) 420 return iface.get_dbus_method(method) 421 422 def find_method_on_bus(self, path, method, bus, interfaces): 423 obj = self.bus.get_object(bus, path, introspect=False) 424 iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE) 425 data = iface.Introspect() 426 parser = IntrospectionNodeParser( 427 ElementTree.fromstring(data), 428 intf_match=lambda x: x in interfaces) 429 for x, y in parser.get_interfaces().items(): 430 m = self.find_method_in_interface( 431 method, obj, x, y.get('method')) 432 if m: 433 self.service = bus 434 self.interface = x 435 return m 436 437 438class PropertyHandler(RouteHandler): 439 verbs = ['PUT', 'GET'] 440 rules = '<path:path>/attr/<prop>' 441 content_type = 'application/json' 442 443 def __init__(self, app, bus): 444 super(PropertyHandler, self).__init__( 445 app, bus, self.verbs, self.rules, self.content_type) 446 447 def find(self, path, prop): 448 self.app.instance_handler.setup(path) 449 obj = self.app.instance_handler.do_get(path) 450 real_name = obmc.utils.misc.find_case_insensitive( 451 prop, list(obj.keys())) 452 453 if not real_name: 454 if request.method == 'PUT': 455 abort(403, _4034_msg % ('property', 'created', prop)) 456 else: 457 abort(404, _4034_msg % ('property', 'found', prop)) 458 return real_name, {path: obj} 459 460 def setup(self, path, prop): 461 name, obj = self.find(path, prop) 462 request.route_data['obj'] = obj 463 request.route_data['name'] = name 464 465 def do_get(self, path, prop): 466 name = request.route_data['name'] 467 return request.route_data['obj'][path][name] 468 469 def do_put(self, path, prop, value=None, retry=True): 470 if value is None: 471 value = request.parameter_list 472 473 prop, iface, properties_iface = self.get_host_interface( 474 path, prop, request.route_data['map'][path]) 475 try: 476 properties_iface.Set(iface, prop, value) 477 except ValueError as e: 478 abort(400, str(e)) 479 except dbus.exceptions.DBusException as e: 480 if e.get_dbus_name() == DBUS_INVALID_ARGS and retry: 481 bus_name = properties_iface.bus_name 482 expected_type = get_type_signature_by_introspection(self.bus, 483 bus_name, 484 path, 485 prop) 486 if not expected_type: 487 abort(403, "Failed to get expected type: %s" % str(e)) 488 converted_value = None 489 try: 490 converted_value = convert_type(expected_type, value) 491 self.do_put(path, prop, converted_value, False) 492 return 493 except Exception as ex: 494 abort(403, "Failed to convert %s to type %s" % 495 (value, expected_type)) 496 abort(403, str(e)) 497 raise 498 499 def get_host_interface(self, path, prop, bus_info): 500 for bus, interfaces in bus_info.items(): 501 obj = self.bus.get_object(bus, path, introspect=True) 502 properties_iface = dbus.Interface( 503 obj, dbus_interface=dbus.PROPERTIES_IFACE) 504 505 info = self.get_host_interface_on_bus( 506 path, prop, properties_iface, bus, interfaces) 507 if info is not None: 508 prop, iface = info 509 return prop, iface, properties_iface 510 511 def get_host_interface_on_bus(self, path, prop, iface, bus, interfaces): 512 for i in interfaces: 513 properties = self.try_properties_interface(iface.GetAll, i) 514 if not properties: 515 continue 516 match = obmc.utils.misc.find_case_insensitive( 517 prop, list(properties.keys())) 518 if match is None: 519 continue 520 prop = match 521 return prop, i 522 523 524class SchemaHandler(RouteHandler): 525 verbs = ['GET'] 526 rules = '<path:path>/schema' 527 528 def __init__(self, app, bus): 529 super(SchemaHandler, self).__init__( 530 app, bus, self.verbs, self.rules) 531 532 def find(self, path): 533 return self.try_mapper_call( 534 self.mapper.get_object, 535 path=path) 536 537 def setup(self, path): 538 request.route_data['map'] = self.find(path) 539 540 def do_get(self, path): 541 schema = {} 542 for x in request.route_data['map'].keys(): 543 obj = self.bus.get_object(x, path, introspect=False) 544 iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE) 545 data = iface.Introspect() 546 parser = IntrospectionNodeParser( 547 ElementTree.fromstring(data)) 548 for x, y in parser.get_interfaces().items(): 549 schema[x] = y 550 551 return schema 552 553 554class InstanceHandler(RouteHandler): 555 verbs = ['GET', 'PUT', 'DELETE'] 556 rules = '<path:path>' 557 request_type = dict 558 559 def __init__(self, app, bus): 560 super(InstanceHandler, self).__init__( 561 app, bus, self.verbs, self.rules) 562 563 def find(self, path, callback=None): 564 return {path: self.try_mapper_call( 565 self.mapper.get_object, 566 callback, 567 path=path)} 568 569 def setup(self, path): 570 callback = None 571 if request.method == 'PUT': 572 def callback(e, **kw): 573 abort(403, _4034_msg % ('resource', 'created', path)) 574 575 if request.route_data.get('map') is None: 576 request.route_data['map'] = self.find(path, callback) 577 578 def do_get(self, path): 579 return self.mapper.enumerate_object( 580 path, 581 mapper_data=request.route_data['map']) 582 583 def do_put(self, path): 584 # make sure all properties exist in the request 585 obj = set(self.do_get(path).keys()) 586 req = set(request.parameter_list.keys()) 587 588 diff = list(obj.difference(req)) 589 if diff: 590 abort(403, _4034_msg % ( 591 'resource', 'removed', '%s/attr/%s' % (path, diff[0]))) 592 593 diff = list(req.difference(obj)) 594 if diff: 595 abort(403, _4034_msg % ( 596 'resource', 'created', '%s/attr/%s' % (path, diff[0]))) 597 598 for p, v in request.parameter_list.items(): 599 self.app.property_handler.do_put( 600 path, p, v) 601 602 def do_delete(self, path): 603 for bus_info in request.route_data['map'][path].items(): 604 if self.bus_missing_delete(path, *bus_info): 605 abort(403, _4034_msg % ('resource', 'removed', path)) 606 607 for bus in request.route_data['map'][path].keys(): 608 self.delete_on_bus(path, bus) 609 610 def bus_missing_delete(self, path, bus, interfaces): 611 return DELETE_IFACE not in interfaces 612 613 def delete_on_bus(self, path, bus): 614 obj = self.bus.get_object(bus, path, introspect=False) 615 delete_iface = dbus.Interface( 616 obj, dbus_interface=DELETE_IFACE) 617 delete_iface.Delete() 618 619 620class SessionHandler(MethodHandler): 621 ''' Handles the /login and /logout routes, manages 622 server side session store and session cookies. ''' 623 624 rules = ['/login', '/logout'] 625 login_str = "User '%s' logged %s" 626 bad_passwd_str = "Invalid username or password" 627 no_user_str = "No user logged in" 628 bad_json_str = "Expecting request format { 'data': " \ 629 "[<username>, <password>] }, got '%s'" 630 _require_auth = None 631 MAX_SESSIONS = 16 632 633 def __init__(self, app, bus): 634 super(SessionHandler, self).__init__( 635 app, bus) 636 self.hmac_key = os.urandom(128) 637 self.session_store = [] 638 639 @staticmethod 640 def authenticate(username, clear): 641 try: 642 encoded = spwd.getspnam(username)[1] 643 return encoded == crypt.crypt(clear, encoded) 644 except KeyError: 645 return False 646 647 def invalidate_session(self, session): 648 try: 649 self.session_store.remove(session) 650 except ValueError: 651 pass 652 653 def new_session(self): 654 sid = os.urandom(32) 655 if self.MAX_SESSIONS <= len(self.session_store): 656 self.session_store.pop() 657 self.session_store.insert(0, {'sid': sid}) 658 659 return self.session_store[0] 660 661 def get_session(self, sid): 662 sids = [x['sid'] for x in self.session_store] 663 try: 664 return self.session_store[sids.index(sid)] 665 except ValueError: 666 return None 667 668 def get_session_from_cookie(self): 669 return self.get_session( 670 request.get_cookie( 671 'sid', secret=self.hmac_key)) 672 673 def do_post(self, **kw): 674 if request.path == '/login': 675 return self.do_login(**kw) 676 else: 677 return self.do_logout(**kw) 678 679 def do_logout(self, **kw): 680 session = self.get_session_from_cookie() 681 if session is not None: 682 user = session['user'] 683 self.invalidate_session(session) 684 response.delete_cookie('sid') 685 return self.login_str % (user, 'out') 686 687 return self.no_user_str 688 689 def do_login(self, **kw): 690 if len(request.parameter_list) != 2: 691 abort(400, self.bad_json_str % (request.json)) 692 693 if not self.authenticate(*request.parameter_list): 694 abort(401, self.bad_passwd_str) 695 696 user = request.parameter_list[0] 697 session = self.new_session() 698 session['user'] = user 699 response.set_cookie( 700 'sid', session['sid'], secret=self.hmac_key, 701 secure=True, 702 httponly=True) 703 return self.login_str % (user, 'in') 704 705 def find(self, **kw): 706 pass 707 708 def setup(self, **kw): 709 pass 710 711 712class ImageUploadUtils: 713 ''' Provides common utils for image upload. ''' 714 715 file_loc = '/tmp/images' 716 file_prefix = 'img' 717 file_suffix = '' 718 signal = None 719 720 @classmethod 721 def do_upload(cls, filename=''): 722 def cleanup(): 723 os.close(handle) 724 if cls.signal: 725 cls.signal.remove() 726 cls.signal = None 727 728 def signal_callback(path, a, **kw): 729 # Just interested on the first Version interface created which is 730 # triggered when the file is uploaded. This helps avoid getting the 731 # wrong information for multiple upload requests in a row. 732 if "xyz.openbmc_project.Software.Version" in a and \ 733 "xyz.openbmc_project.Software.Activation" not in a: 734 paths.append(path) 735 736 while cls.signal: 737 # Serialize uploads by waiting for the signal to be cleared. 738 # This makes it easier to ensure that the version information 739 # is the right one instead of the data from another upload request. 740 gevent.sleep(1) 741 if not os.path.exists(cls.file_loc): 742 abort(500, "Error Directory not found") 743 paths = [] 744 bus = dbus.SystemBus() 745 cls.signal = bus.add_signal_receiver( 746 signal_callback, 747 dbus_interface=dbus.BUS_DAEMON_IFACE + '.ObjectManager', 748 signal_name='InterfacesAdded', 749 path=SOFTWARE_PATH) 750 if not filename: 751 handle, filename = tempfile.mkstemp(cls.file_suffix, 752 cls.file_prefix, cls.file_loc) 753 else: 754 filename = os.path.join(cls.file_loc, filename) 755 handle = os.open(filename, os.O_WRONLY | os.O_CREAT) 756 try: 757 file_contents = request.body.read() 758 request.body.close() 759 os.write(handle, file_contents) 760 # Close file after writing, the image manager process watches for 761 # the close event to know the upload is complete. 762 os.close(handle) 763 except (IOError, ValueError) as e: 764 cleanup() 765 abort(400, str(e)) 766 except Exception: 767 cleanup() 768 abort(400, "Unexpected Error") 769 loop = gobject.MainLoop() 770 gcontext = loop.get_context() 771 count = 0 772 version_id = '' 773 while loop is not None: 774 try: 775 if gcontext.pending(): 776 gcontext.iteration() 777 if not paths: 778 gevent.sleep(1) 779 else: 780 version_id = os.path.basename(paths.pop()) 781 break 782 count += 1 783 if count == 10: 784 break 785 except Exception: 786 break 787 cls.signal.remove() 788 cls.signal = None 789 if version_id: 790 return version_id 791 else: 792 abort(400, "Version already exists or failed to be extracted") 793 794 795class ImagePostHandler(RouteHandler): 796 ''' Handles the /upload/image route. ''' 797 798 verbs = ['POST'] 799 rules = ['/upload/image'] 800 content_type = 'application/octet-stream' 801 802 def __init__(self, app, bus): 803 super(ImagePostHandler, self).__init__( 804 app, bus, self.verbs, self.rules, self.content_type) 805 806 def do_post(self, filename=''): 807 return ImageUploadUtils.do_upload() 808 809 def find(self, **kw): 810 pass 811 812 def setup(self, **kw): 813 pass 814 815 816class EventNotifier: 817 keyNames = {} 818 keyNames['event'] = 'event' 819 keyNames['path'] = 'path' 820 keyNames['intfMap'] = 'interfaces' 821 keyNames['propMap'] = 'properties' 822 keyNames['intf'] = 'interface' 823 824 def __init__(self, wsock, filters): 825 self.wsock = wsock 826 self.paths = filters.get("paths", []) 827 self.interfaces = filters.get("interfaces", []) 828 if not self.paths: 829 self.paths.append(None) 830 bus = dbus.SystemBus() 831 # Add a signal receiver for every path the client is interested in 832 for path in self.paths: 833 bus.add_signal_receiver( 834 self.interfaces_added_handler, 835 dbus_interface=dbus.BUS_DAEMON_IFACE + '.ObjectManager', 836 signal_name='InterfacesAdded', 837 path=path) 838 bus.add_signal_receiver( 839 self.properties_changed_handler, 840 dbus_interface=dbus.PROPERTIES_IFACE, 841 signal_name='PropertiesChanged', 842 path=path, 843 path_keyword='path') 844 loop = gobject.MainLoop() 845 # gobject's mainloop.run() will block the entire process, so the gevent 846 # scheduler and hence greenlets won't execute. The while-loop below 847 # works around this limitation by using gevent's sleep, instead of 848 # calling loop.run() 849 gcontext = loop.get_context() 850 while loop is not None: 851 try: 852 if gcontext.pending(): 853 gcontext.iteration() 854 else: 855 # gevent.sleep puts only the current greenlet to sleep, 856 # not the entire process. 857 gevent.sleep(5) 858 except WebSocketError: 859 break 860 861 def interfaces_added_handler(self, path, iprops, **kw): 862 ''' If the client is interested in these changes, respond to the 863 client. This handles d-bus interface additions.''' 864 if (not self.interfaces) or \ 865 (not set(iprops).isdisjoint(self.interfaces)): 866 response = {} 867 response[self.keyNames['event']] = "InterfacesAdded" 868 response[self.keyNames['path']] = path 869 response[self.keyNames['intfMap']] = iprops 870 try: 871 self.wsock.send(json.dumps(response)) 872 except WebSocketError: 873 return 874 875 def properties_changed_handler(self, interface, new, old, **kw): 876 ''' If the client is interested in these changes, respond to the 877 client. This handles d-bus property changes. ''' 878 if (not self.interfaces) or (interface in self.interfaces): 879 path = str(kw['path']) 880 response = {} 881 response[self.keyNames['event']] = "PropertiesChanged" 882 response[self.keyNames['path']] = path 883 response[self.keyNames['intf']] = interface 884 response[self.keyNames['propMap']] = new 885 try: 886 self.wsock.send(json.dumps(response)) 887 except WebSocketError: 888 return 889 890 891class EventHandler(RouteHandler): 892 ''' Handles the /subscribe route, for clients to be able 893 to subscribe to BMC events. ''' 894 895 verbs = ['GET'] 896 rules = ['/subscribe'] 897 898 def __init__(self, app, bus): 899 super(EventHandler, self).__init__( 900 app, bus, self.verbs, self.rules) 901 902 def find(self, **kw): 903 pass 904 905 def setup(self, **kw): 906 pass 907 908 def do_get(self): 909 wsock = request.environ.get('wsgi.websocket') 910 if not wsock: 911 abort(400, 'Expected WebSocket request.') 912 filters = wsock.receive() 913 filters = json.loads(filters) 914 notifier = EventNotifier(wsock, filters) 915 916 917class HostConsoleHandler(RouteHandler): 918 ''' Handles the /console route, for clients to be able 919 read/write the host serial console. The way this is 920 done is by exposing a websocket that's mirrored to an 921 abstract UNIX domain socket, which is the source for 922 the console data. ''' 923 924 verbs = ['GET'] 925 # Naming the route console0, because the numbering will help 926 # on multi-bmc/multi-host systems. 927 rules = ['/console0'] 928 929 def __init__(self, app, bus): 930 super(HostConsoleHandler, self).__init__( 931 app, bus, self.verbs, self.rules) 932 933 def find(self, **kw): 934 pass 935 936 def setup(self, **kw): 937 pass 938 939 def read_wsock(self, wsock, sock): 940 while True: 941 try: 942 incoming = wsock.receive() 943 if incoming: 944 # Read websocket, write to UNIX socket 945 sock.send(incoming) 946 except Exception as e: 947 sock.close() 948 return 949 950 def read_sock(self, sock, wsock): 951 max_sock_read_len = 4096 952 while True: 953 try: 954 outgoing = sock.recv(max_sock_read_len) 955 if outgoing: 956 # Read UNIX socket, write to websocket 957 wsock.send(outgoing) 958 except Exception as e: 959 wsock.close() 960 return 961 962 def send_ping(self, wsock) : 963 # Most webservers close websockets after 60 seconds of 964 # inactivity. Make sure to send a ping before that. 965 timeout = 45 966 payload = "ping" 967 # the ping payload can be anything, the receiver has to just 968 # return the same back. 969 while True: 970 gevent.sleep(timeout) 971 wsock.send_frame(payload, wsock.OPCODE_PING) 972 973 def do_get(self): 974 wsock = request.environ.get('wsgi.websocket') 975 if not wsock: 976 abort(400, 'Expected WebSocket based request.') 977 978 # A UNIX domain socket structure defines a 108-byte pathname. The 979 # server in this case, obmc-console-server, expects a 108-byte path. 980 socket_name = "\0obmc-console" 981 trailing_bytes = "\0" * (108 - len(socket_name)) 982 socket_path = socket_name + trailing_bytes 983 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 984 985 try: 986 sock.connect(socket_path) 987 except Exception as e: 988 abort(500, str(e)) 989 990 wsock_reader = Greenlet.spawn(self.read_wsock, wsock, sock) 991 sock_reader = Greenlet.spawn(self.read_sock, sock, wsock) 992 ping_sender = Greenlet.spawn(self.send_ping, wsock) 993 gevent.joinall([wsock_reader, sock_reader, ping_sender]) 994 995 996class ImagePutHandler(RouteHandler): 997 ''' Handles the /upload/image/<filename> route. ''' 998 999 verbs = ['PUT'] 1000 rules = ['/upload/image/<filename>'] 1001 content_type = 'application/octet-stream' 1002 1003 def __init__(self, app, bus): 1004 super(ImagePutHandler, self).__init__( 1005 app, bus, self.verbs, self.rules, self.content_type) 1006 1007 def do_put(self, filename=''): 1008 return ImageUploadUtils.do_upload(filename) 1009 1010 def find(self, **kw): 1011 pass 1012 1013 def setup(self, **kw): 1014 pass 1015 1016 1017class DownloadDumpHandler(RouteHandler): 1018 ''' Handles the /download/dump route. ''' 1019 1020 verbs = 'GET' 1021 rules = ['/download/dump/<dumpid>'] 1022 content_type = 'application/octet-stream' 1023 dump_loc = '/var/lib/phosphor-debug-collector/dumps' 1024 suppress_json_resp = True 1025 1026 def __init__(self, app, bus): 1027 super(DownloadDumpHandler, self).__init__( 1028 app, bus, self.verbs, self.rules, self.content_type) 1029 1030 def do_get(self, dumpid): 1031 return self.do_download(dumpid) 1032 1033 def find(self, **kw): 1034 pass 1035 1036 def setup(self, **kw): 1037 pass 1038 1039 def do_download(self, dumpid): 1040 dump_loc = os.path.join(self.dump_loc, dumpid) 1041 if not os.path.exists(dump_loc): 1042 abort(404, "Path not found") 1043 1044 files = os.listdir(dump_loc) 1045 num_files = len(files) 1046 if num_files == 0: 1047 abort(404, "Dump not found") 1048 1049 return static_file(os.path.basename(files[0]), root=dump_loc, 1050 download=True, mimetype=self.content_type) 1051 1052 1053class WebHandler(RouteHandler): 1054 ''' Handles the routes for the web UI files. ''' 1055 1056 verbs = 'GET' 1057 1058 # Match only what we know are web files, so everything else 1059 # can get routed to the REST handlers. 1060 rules = ['//', '/<filename:re:.+\.js>', '/<filename:re:.+\.svg>', 1061 '/<filename:re:.+\.css>', '/<filename:re:.+\.ttf>', 1062 '/<filename:re:.+\.eot>', '/<filename:re:.+\.woff>', 1063 '/<filename:re:.+\.woff2>', '/<filename:re:.+\.map>', 1064 '/<filename:re:.+\.png>', '/<filename:re:.+\.html>', 1065 '/<filename:re:.+\.ico>'] 1066 1067 # The mimetypes module knows about most types, but not these 1068 content_types = { 1069 '.eot': 'application/vnd.ms-fontobject', 1070 '.woff': 'application/x-font-woff', 1071 '.woff2': 'application/x-font-woff2', 1072 '.ttf': 'application/x-font-ttf', 1073 '.map': 'application/json' 1074 } 1075 1076 _require_auth = None 1077 suppress_json_resp = True 1078 1079 def __init__(self, app, bus): 1080 super(WebHandler, self).__init__( 1081 app, bus, self.verbs, self.rules) 1082 1083 def get_type(self, filename): 1084 ''' Returns the content type and encoding for a file ''' 1085 1086 content_type, encoding = mimetypes.guess_type(filename) 1087 1088 # Try our own list if mimetypes didn't recognize it 1089 if content_type is None: 1090 if filename[-3:] == '.gz': 1091 filename = filename[:-3] 1092 extension = filename[filename.rfind('.'):] 1093 content_type = self.content_types.get(extension, None) 1094 1095 return content_type, encoding 1096 1097 def do_get(self, filename='index.html'): 1098 1099 # If a gzipped version exists, use that instead. 1100 # Possible future enhancement: if the client doesn't 1101 # accept compressed files, unzip it ourselves before sending. 1102 if not os.path.exists(os.path.join(www_base_path, filename)): 1103 filename = filename + '.gz' 1104 1105 # Though bottle should protect us, ensure path is valid 1106 realpath = os.path.realpath(filename) 1107 if realpath[0] == '/': 1108 realpath = realpath[1:] 1109 if not os.path.exists(os.path.join(www_base_path, realpath)): 1110 abort(404, "Path not found") 1111 1112 mimetype, encoding = self.get_type(filename) 1113 1114 # Couldn't find the type - let static_file() deal with it, 1115 # though this should never happen. 1116 if mimetype is None: 1117 print("Can't figure out content-type for %s" % filename) 1118 mimetype = 'auto' 1119 1120 # This call will set several header fields for us, 1121 # including the charset if the type is text. 1122 response = static_file(filename, www_base_path, mimetype) 1123 1124 # static_file() will only set the encoding if the 1125 # mimetype was auto, so set it here. 1126 if encoding is not None: 1127 response.set_header('Content-Encoding', encoding) 1128 1129 return response 1130 1131 def find(self, **kw): 1132 pass 1133 1134 def setup(self, **kw): 1135 pass 1136 1137 1138class AuthorizationPlugin(object): 1139 ''' Invokes an optional list of authorization callbacks. ''' 1140 1141 name = 'authorization' 1142 api = 2 1143 1144 class Compose: 1145 def __init__(self, validators, callback, session_mgr): 1146 self.validators = validators 1147 self.callback = callback 1148 self.session_mgr = session_mgr 1149 1150 def __call__(self, *a, **kw): 1151 sid = request.get_cookie('sid', secret=self.session_mgr.hmac_key) 1152 session = self.session_mgr.get_session(sid) 1153 if request.method != 'OPTIONS': 1154 for x in self.validators: 1155 x(session, *a, **kw) 1156 1157 return self.callback(*a, **kw) 1158 1159 def apply(self, callback, route): 1160 undecorated = route.get_undecorated_callback() 1161 if not isinstance(undecorated, RouteHandler): 1162 return callback 1163 1164 auth_types = getattr( 1165 undecorated, '_require_auth', None) 1166 if not auth_types: 1167 return callback 1168 1169 return self.Compose( 1170 auth_types, callback, undecorated.app.session_handler) 1171 1172 1173class CorsPlugin(object): 1174 ''' Add CORS headers. ''' 1175 1176 name = 'cors' 1177 api = 2 1178 1179 @staticmethod 1180 def process_origin(): 1181 origin = request.headers.get('Origin') 1182 if origin: 1183 response.add_header('Access-Control-Allow-Origin', origin) 1184 response.add_header( 1185 'Access-Control-Allow-Credentials', 'true') 1186 1187 @staticmethod 1188 def process_method_and_headers(verbs): 1189 method = request.headers.get('Access-Control-Request-Method') 1190 headers = request.headers.get('Access-Control-Request-Headers') 1191 if headers: 1192 headers = [x.lower() for x in headers.split(',')] 1193 1194 if method in verbs \ 1195 and headers == ['content-type']: 1196 response.add_header('Access-Control-Allow-Methods', method) 1197 response.add_header( 1198 'Access-Control-Allow-Headers', 'Content-Type') 1199 response.add_header('X-Frame-Options', 'deny') 1200 response.add_header('X-Content-Type-Options', 'nosniff') 1201 response.add_header('X-XSS-Protection', '1; mode=block') 1202 response.add_header( 1203 'Content-Security-Policy', "default-src 'self'") 1204 response.add_header( 1205 'Strict-Transport-Security', 1206 'max-age=31536000; includeSubDomains; preload') 1207 1208 def __init__(self, app): 1209 app.install_error_callback(self.error_callback) 1210 1211 def apply(self, callback, route): 1212 undecorated = route.get_undecorated_callback() 1213 if not isinstance(undecorated, RouteHandler): 1214 return callback 1215 1216 if not getattr(undecorated, '_enable_cors', None): 1217 return callback 1218 1219 def wrap(*a, **kw): 1220 self.process_origin() 1221 self.process_method_and_headers(undecorated._verbs) 1222 return callback(*a, **kw) 1223 1224 return wrap 1225 1226 def error_callback(self, **kw): 1227 self.process_origin() 1228 1229 1230class JsonApiRequestPlugin(object): 1231 ''' Ensures request content satisfies the OpenBMC json api format. ''' 1232 name = 'json_api_request' 1233 api = 2 1234 1235 error_str = "Expecting request format { 'data': <value> }, got '%s'" 1236 type_error_str = "Unsupported Content-Type: '%s'" 1237 json_type = "application/json" 1238 request_methods = ['PUT', 'POST', 'PATCH'] 1239 1240 @staticmethod 1241 def content_expected(): 1242 return request.method in JsonApiRequestPlugin.request_methods 1243 1244 def validate_request(self): 1245 if request.content_length > 0 and \ 1246 request.content_type != self.json_type: 1247 abort(415, self.type_error_str % request.content_type) 1248 1249 try: 1250 request.parameter_list = request.json.get('data') 1251 except ValueError as e: 1252 abort(400, str(e)) 1253 except (AttributeError, KeyError, TypeError): 1254 abort(400, self.error_str % request.json) 1255 1256 def apply(self, callback, route): 1257 content_type = getattr( 1258 route.get_undecorated_callback(), '_content_type', None) 1259 if self.json_type != content_type: 1260 return callback 1261 1262 verbs = getattr( 1263 route.get_undecorated_callback(), '_verbs', None) 1264 if verbs is None: 1265 return callback 1266 1267 if not set(self.request_methods).intersection(verbs): 1268 return callback 1269 1270 def wrap(*a, **kw): 1271 if self.content_expected(): 1272 self.validate_request() 1273 return callback(*a, **kw) 1274 1275 return wrap 1276 1277 1278class JsonApiRequestTypePlugin(object): 1279 ''' Ensures request content type satisfies the OpenBMC json api format. ''' 1280 name = 'json_api_method_request' 1281 api = 2 1282 1283 error_str = "Expecting request format { 'data': %s }, got '%s'" 1284 json_type = "application/json" 1285 1286 def apply(self, callback, route): 1287 content_type = getattr( 1288 route.get_undecorated_callback(), '_content_type', None) 1289 if self.json_type != content_type: 1290 return callback 1291 1292 request_type = getattr( 1293 route.get_undecorated_callback(), 'request_type', None) 1294 if request_type is None: 1295 return callback 1296 1297 def validate_request(): 1298 if not isinstance(request.parameter_list, request_type): 1299 abort(400, self.error_str % (str(request_type), request.json)) 1300 1301 def wrap(*a, **kw): 1302 if JsonApiRequestPlugin.content_expected(): 1303 validate_request() 1304 return callback(*a, **kw) 1305 1306 return wrap 1307 1308 1309class JsonErrorsPlugin(JSONPlugin): 1310 ''' Extend the Bottle JSONPlugin such that it also encodes error 1311 responses. ''' 1312 1313 def __init__(self, app, **kw): 1314 super(JsonErrorsPlugin, self).__init__(**kw) 1315 self.json_opts = { 1316 x: y for x, y in kw.items() 1317 if x in ['indent', 'sort_keys']} 1318 app.install_error_callback(self.error_callback) 1319 1320 def error_callback(self, response_object, response_body, **kw): 1321 response_body['body'] = json.dumps(response_object, **self.json_opts) 1322 response.content_type = 'application/json' 1323 1324 1325class JsonApiResponsePlugin(object): 1326 ''' Emits responses in the OpenBMC json api format. ''' 1327 name = 'json_api_response' 1328 api = 2 1329 1330 @staticmethod 1331 def has_body(): 1332 return request.method not in ['OPTIONS'] 1333 1334 def __init__(self, app): 1335 app.install_error_callback(self.error_callback) 1336 1337 def apply(self, callback, route): 1338 skip = getattr( 1339 route.get_undecorated_callback(), 'suppress_json_resp', None) 1340 if skip: 1341 return callback 1342 1343 def wrap(*a, **kw): 1344 data = callback(*a, **kw) 1345 if self.has_body(): 1346 resp = {'data': data} 1347 resp['status'] = 'ok' 1348 resp['message'] = response.status_line 1349 return resp 1350 return wrap 1351 1352 def error_callback(self, error, response_object, **kw): 1353 response_object['message'] = error.status_line 1354 response_object['status'] = 'error' 1355 response_object.setdefault('data', {})['description'] = str(error.body) 1356 if error.status_code == 500: 1357 response_object['data']['exception'] = repr(error.exception) 1358 response_object['data']['traceback'] = error.traceback.splitlines() 1359 1360 1361class JsonpPlugin(object): 1362 ''' Json javascript wrapper. ''' 1363 name = 'jsonp' 1364 api = 2 1365 1366 def __init__(self, app, **kw): 1367 app.install_error_callback(self.error_callback) 1368 1369 @staticmethod 1370 def to_jsonp(json): 1371 jwrapper = request.query.callback or None 1372 if(jwrapper): 1373 response.set_header('Content-Type', 'application/javascript') 1374 json = jwrapper + '(' + json + ');' 1375 return json 1376 1377 def apply(self, callback, route): 1378 def wrap(*a, **kw): 1379 return self.to_jsonp(callback(*a, **kw)) 1380 return wrap 1381 1382 def error_callback(self, response_body, **kw): 1383 response_body['body'] = self.to_jsonp(response_body['body']) 1384 1385 1386class ContentCheckerPlugin(object): 1387 ''' Ensures that a route is associated with the expected content-type 1388 header. ''' 1389 name = 'content_checker' 1390 api = 2 1391 1392 class Checker: 1393 def __init__(self, type, callback): 1394 self.expected_type = type 1395 self.callback = callback 1396 self.error_str = "Expecting content type '%s', got '%s'" 1397 1398 def __call__(self, *a, **kw): 1399 if request.method in ['PUT', 'POST', 'PATCH'] and \ 1400 self.expected_type and \ 1401 self.expected_type != request.content_type: 1402 abort(415, self.error_str % (self.expected_type, 1403 request.content_type)) 1404 1405 return self.callback(*a, **kw) 1406 1407 def apply(self, callback, route): 1408 content_type = getattr( 1409 route.get_undecorated_callback(), '_content_type', None) 1410 1411 return self.Checker(content_type, callback) 1412 1413 1414class App(Bottle): 1415 def __init__(self, **kw): 1416 super(App, self).__init__(autojson=False) 1417 1418 self.have_wsock = kw.get('have_wsock', False) 1419 1420 self.bus = dbus.SystemBus() 1421 self.mapper = obmc.mapper.Mapper(self.bus) 1422 self.error_callbacks = [] 1423 1424 self.install_hooks() 1425 self.install_plugins() 1426 self.create_handlers() 1427 self.install_handlers() 1428 1429 def install_plugins(self): 1430 # install json api plugins 1431 json_kw = {'indent': 2, 'sort_keys': True} 1432 self.install(AuthorizationPlugin()) 1433 self.install(CorsPlugin(self)) 1434 self.install(ContentCheckerPlugin()) 1435 self.install(JsonpPlugin(self, **json_kw)) 1436 self.install(JsonErrorsPlugin(self, **json_kw)) 1437 self.install(JsonApiResponsePlugin(self)) 1438 self.install(JsonApiRequestPlugin()) 1439 self.install(JsonApiRequestTypePlugin()) 1440 1441 def install_hooks(self): 1442 self.error_handler_type = type(self.default_error_handler) 1443 self.original_error_handler = self.default_error_handler 1444 self.default_error_handler = self.error_handler_type( 1445 self.custom_error_handler, self, Bottle) 1446 1447 self.real_router_match = self.router.match 1448 self.router.match = self.custom_router_match 1449 self.add_hook('before_request', self.strip_extra_slashes) 1450 1451 def create_handlers(self): 1452 # create route handlers 1453 self.session_handler = SessionHandler(self, self.bus) 1454 self.web_handler = WebHandler(self, self.bus) 1455 self.directory_handler = DirectoryHandler(self, self.bus) 1456 self.list_names_handler = ListNamesHandler(self, self.bus) 1457 self.list_handler = ListHandler(self, self.bus) 1458 self.method_handler = MethodHandler(self, self.bus) 1459 self.property_handler = PropertyHandler(self, self.bus) 1460 self.schema_handler = SchemaHandler(self, self.bus) 1461 self.image_upload_post_handler = ImagePostHandler(self, self.bus) 1462 self.image_upload_put_handler = ImagePutHandler(self, self.bus) 1463 self.download_dump_get_handler = DownloadDumpHandler(self, self.bus) 1464 if self.have_wsock: 1465 self.event_handler = EventHandler(self, self.bus) 1466 self.host_console_handler = HostConsoleHandler(self, self.bus) 1467 self.instance_handler = InstanceHandler(self, self.bus) 1468 1469 def install_handlers(self): 1470 self.session_handler.install() 1471 self.web_handler.install() 1472 self.directory_handler.install() 1473 self.list_names_handler.install() 1474 self.list_handler.install() 1475 self.method_handler.install() 1476 self.property_handler.install() 1477 self.schema_handler.install() 1478 self.image_upload_post_handler.install() 1479 self.image_upload_put_handler.install() 1480 self.download_dump_get_handler.install() 1481 if self.have_wsock: 1482 self.event_handler.install() 1483 self.host_console_handler.install() 1484 # this has to come last, since it matches everything 1485 self.instance_handler.install() 1486 1487 def install_error_callback(self, callback): 1488 self.error_callbacks.insert(0, callback) 1489 1490 def custom_router_match(self, environ): 1491 ''' The built-in Bottle algorithm for figuring out if a 404 or 405 is 1492 needed doesn't work for us since the instance rules match 1493 everything. This monkey-patch lets the route handler figure 1494 out which response is needed. This could be accomplished 1495 with a hook but that would require calling the router match 1496 function twice. 1497 ''' 1498 route, args = self.real_router_match(environ) 1499 if isinstance(route.callback, RouteHandler): 1500 route.callback._setup(**args) 1501 1502 return route, args 1503 1504 def custom_error_handler(self, res, error): 1505 ''' Allow plugins to modify error responses too via this custom 1506 error handler. ''' 1507 1508 response_object = {} 1509 response_body = {} 1510 for x in self.error_callbacks: 1511 x(error=error, 1512 response_object=response_object, 1513 response_body=response_body) 1514 1515 return response_body.get('body', "") 1516 1517 @staticmethod 1518 def strip_extra_slashes(): 1519 path = request.environ['PATH_INFO'] 1520 trailing = ("", "/")[path[-1] == '/'] 1521 parts = list(filter(bool, path.split('/'))) 1522 request.environ['PATH_INFO'] = '/' + '/'.join(parts) + trailing 1523