PageRenderTime 43ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/src/mailman/rest/wsgiapp.py

https://gitlab.com/noc0lour/mailman
Python | 233 lines | 159 code | 13 blank | 61 comment | 19 complexity | edff52979cd6a2387a8d03a92ad156a0 MD5 | raw file
  1. # Copyright (C) 2010-2016 by the Free Software Foundation, Inc.
  2. #
  3. # This file is part of GNU Mailman.
  4. #
  5. # GNU Mailman is free software: you can redistribute it and/or modify it under
  6. # the terms of the GNU General Public License as published by the Free
  7. # Software Foundation, either version 3 of the License, or (at your option)
  8. # any later version.
  9. #
  10. # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
  11. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  12. # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
  13. # more details.
  14. #
  15. # You should have received a copy of the GNU General Public License along with
  16. # GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
  17. """Basic WSGI Application object for REST server."""
  18. import re
  19. import logging
  20. from base64 import b64decode
  21. from falcon import API, HTTPUnauthorized
  22. from falcon.routing import create_http_method_map
  23. from mailman import public
  24. from mailman.config import config
  25. from mailman.database.transaction import transactional
  26. from mailman.rest.root import Root
  27. from wsgiref.simple_server import (
  28. WSGIRequestHandler, WSGIServer, make_server as wsgi_server)
  29. log = logging.getLogger('mailman.http')
  30. MISSING = object()
  31. SLASH = '/'
  32. EMPTYSTRING = ''
  33. REALM = 'mailman3-rest'
  34. class AdminWSGIServer(WSGIServer):
  35. """Server class that integrates error handling with our log files."""
  36. def handle_error(self, request, client_address):
  37. # Interpose base class method so that the exception gets printed to
  38. # our log file rather than stderr.
  39. log.exception('REST server exception during request from %s',
  40. client_address)
  41. class StderrLogger:
  42. def __init__(self):
  43. self._buffer = []
  44. def write(self, message):
  45. self._buffer.append(message)
  46. def flush(self):
  47. self._buffer.insert(0, 'REST request handler error:\n')
  48. log.error(EMPTYSTRING.join(self._buffer))
  49. self._buffer = []
  50. class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler):
  51. """Handler class which just logs output to the right place."""
  52. def log_message(self, format, *args):
  53. """See `BaseHTTPRequestHandler`."""
  54. log.info('%s - - %s', self.address_string(), format % args)
  55. def get_stderr(self):
  56. # Return a fake stderr object that will actually write its output to
  57. # the log file.
  58. return StderrLogger()
  59. class Middleware:
  60. """Falcon middleware object for Mailman's REST API.
  61. This does two things. It sets the API version on the resource
  62. object, and it verifies that the proper authentication has been
  63. performed.
  64. """
  65. def process_resource(self, request, response, resource, params):
  66. # Set this attribute on the resource right before it is dispatched to.
  67. # This can be used by the resource to provide different responses
  68. # based on the API version, and for path_to() to provide an API
  69. # version-specific path.
  70. resource.api = params.pop('api')
  71. # Check the authorization credentials.
  72. authorized = False
  73. if request.auth is not None and request.auth.startswith('Basic '):
  74. # b64decode() returns bytes, but we require a str.
  75. credentials = b64decode(request.auth[6:]).decode('utf-8')
  76. username, password = credentials.split(':', 1)
  77. if (username == config.webservice.admin_user and
  78. password == config.webservice.admin_pass):
  79. authorized = True
  80. if not authorized:
  81. # Not authorized.
  82. raise HTTPUnauthorized(
  83. '401 Unauthorized',
  84. 'REST API authorization failed',
  85. challenges=['Basic realm=Mailman3'])
  86. class ObjectRouter:
  87. def __init__(self, root):
  88. self._root = root
  89. def add_route(self, uri_template, method_map, resource):
  90. # We don't need this method for object-based routing.
  91. raise NotImplementedError
  92. def find(self, uri):
  93. segments = uri.split(SLASH)
  94. # Since the path is always rooted at /, skip the first segment, which
  95. # will always be the empty string.
  96. segments.pop(0)
  97. this_segment = segments.pop(0)
  98. resource = self._root
  99. context = {}
  100. while True:
  101. # See if any of the resource's child links match the next segment.
  102. for name in dir(resource):
  103. if name.startswith('__') and name.endswith('__'):
  104. continue
  105. attribute = getattr(resource, name, MISSING)
  106. assert attribute is not MISSING, name
  107. matcher = getattr(attribute, '__matcher__', MISSING)
  108. if matcher is MISSING:
  109. continue
  110. result = None
  111. if isinstance(matcher, str):
  112. # Is the matcher string a regular expression or plain
  113. # string? If it starts with a caret, it's a regexp.
  114. if matcher.startswith('^'):
  115. cre = re.compile(matcher)
  116. # Search against the entire remaining path.
  117. tmp_segments = segments[:]
  118. tmp_segments.insert(0, this_segment)
  119. remaining_path = SLASH.join(tmp_segments)
  120. mo = cre.match(remaining_path)
  121. if mo:
  122. result = attribute(
  123. context, segments, **mo.groupdict())
  124. elif matcher == this_segment:
  125. result = attribute(context, segments)
  126. else:
  127. # The matcher is a callable. It returns None if it
  128. # doesn't match, and if it does, it returns a 3-tuple
  129. # containing the positional arguments, the keyword
  130. # arguments, and the remaining segments. The attribute is
  131. # then called with these arguments. Note that the matcher
  132. # wants to see the full remaining path components, which
  133. # includes the current hop.
  134. tmp_segments = segments[:]
  135. tmp_segments.insert(0, this_segment)
  136. matcher_result = matcher(tmp_segments)
  137. if matcher_result is not None:
  138. positional, keyword, segments = matcher_result
  139. result = attribute(
  140. context, segments, *positional, **keyword)
  141. # The attribute could return a 2-tuple giving the resource and
  142. # remaining path segments, or it could just return the result.
  143. # Of course, if the result is None, then the matcher did not
  144. # match.
  145. if result is None:
  146. continue
  147. elif isinstance(result, tuple):
  148. resource, segments = result
  149. else:
  150. resource = result
  151. # The method could have truncated the remaining segments,
  152. # meaning, it's consumed all the path segments, or this is the
  153. # last path segment. In that case the resource we're left at
  154. # is the responder.
  155. if len(segments) == 0:
  156. # We're at the end of the path, so the root must be the
  157. # responder.
  158. method_map = create_http_method_map(resource)
  159. return resource, method_map, context
  160. this_segment = segments.pop(0)
  161. break
  162. else:
  163. # None of the attributes matched this path component, so the
  164. # response is a 404.
  165. return None, None, None
  166. class RootedAPI(API):
  167. def __init__(self, root, *args, **kws):
  168. super().__init__(
  169. *args,
  170. middleware=Middleware(),
  171. router=ObjectRouter(root),
  172. **kws)
  173. # Let Falcon parse the form data into the request object's
  174. # .params attribute.
  175. self.req_options.auto_parse_form_urlencoded = True
  176. # Override the base class implementation to wrap a transactional
  177. # handler around the call, so that the current transaction is
  178. # committed if no errors occur, and aborted otherwise.
  179. @transactional
  180. def __call__(self, environ, start_response):
  181. return super().__call__(environ, start_response)
  182. @public
  183. def make_application():
  184. """Create the WSGI application.
  185. Use this if you want to integrate Mailman's REST server with your own WSGI
  186. server.
  187. """
  188. return RootedAPI(Root())
  189. @public
  190. def make_server():
  191. """Create the Mailman REST server.
  192. Use this if you just want to run Mailman's wsgiref-based REST server.
  193. """
  194. host = config.webservice.hostname
  195. port = int(config.webservice.port)
  196. server = wsgi_server(
  197. host, port, make_application(),
  198. server_class=AdminWSGIServer,
  199. handler_class=AdminWebServiceWSGIRequestHandler)
  200. return server