REST: allow setting a member's moderation_action to None
[mailman.git] / src / mailman / rest / wsgiapp.py
blobbfe17af73348653a55529374aa80311bbabecc1b
1 # Copyright (C) 2010-2016 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
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.
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.
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/>.
18 """Basic WSGI Application object for REST server."""
20 import re
21 import logging
23 from base64 import b64decode
24 from falcon import API, HTTPUnauthorized
25 from falcon.routing import create_http_method_map
26 from mailman import public
27 from mailman.config import config
28 from mailman.database.transaction import transactional
29 from mailman.rest.root import Root
30 from wsgiref.simple_server import (
31 WSGIRequestHandler, WSGIServer, make_server as wsgi_server)
34 log = logging.getLogger('mailman.http')
36 MISSING = object()
37 SLASH = '/'
38 EMPTYSTRING = ''
39 REALM = 'mailman3-rest'
42 class AdminWSGIServer(WSGIServer):
43 """Server class that integrates error handling with our log files."""
45 def handle_error(self, request, client_address):
46 # Interpose base class method so that the exception gets printed to
47 # our log file rather than stderr.
48 log.exception('REST server exception during request from %s',
49 client_address)
52 class StderrLogger:
53 def __init__(self):
54 self._buffer = []
56 def write(self, message):
57 self._buffer.append(message)
59 def flush(self):
60 self._buffer.insert(0, 'REST request handler error:\n')
61 log.error(EMPTYSTRING.join(self._buffer))
62 self._buffer = []
65 class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler):
66 """Handler class which just logs output to the right place."""
68 def log_message(self, format, *args):
69 """See `BaseHTTPRequestHandler`."""
70 log.info('%s - - %s', self.address_string(), format % args)
72 def get_stderr(self):
73 # Return a fake stderr object that will actually write its output to
74 # the log file.
75 return StderrLogger()
78 class Middleware:
79 """Falcon middleware object for Mailman's REST API.
81 This does two things. It sets the API version on the resource
82 object, and it verifies that the proper authentication has been
83 performed.
84 """
85 def process_resource(self, request, response, resource, params):
86 # Check the authorization credentials.
87 authorized = False
88 if request.auth is not None and request.auth.startswith('Basic '):
89 # b64decode() returns bytes, but we require a str.
90 credentials = b64decode(request.auth[6:]).decode('utf-8')
91 username, password = credentials.split(':', 1)
92 if (username == config.webservice.admin_user and
93 password == config.webservice.admin_pass):
94 authorized = True
95 if not authorized:
96 # Not authorized.
97 raise HTTPUnauthorized(
98 '401 Unauthorized',
99 'REST API authorization failed',
100 challenges=['Basic realm=Mailman3'])
103 class ObjectRouter:
104 def __init__(self, root):
105 self._root = root
107 def add_route(self, uri_template, method_map, resource):
108 # We don't need this method for object-based routing.
109 raise NotImplementedError
111 def find(self, uri):
112 segments = uri.split(SLASH)
113 # Since the path is always rooted at /, skip the first segment, which
114 # will always be the empty string.
115 segments.pop(0)
116 this_segment = segments.pop(0)
117 resource = self._root
118 context = {}
119 while True:
120 # Plumb the API through to all child resources.
121 api = getattr(resource, 'api', None)
122 # See if any of the resource's child links match the next segment.
123 for name in dir(resource):
124 if name.startswith('__') and name.endswith('__'):
125 continue
126 attribute = getattr(resource, name, MISSING)
127 assert attribute is not MISSING, name
128 matcher = getattr(attribute, '__matcher__', MISSING)
129 if matcher is MISSING:
130 continue
131 result = None
132 if isinstance(matcher, str):
133 # Is the matcher string a regular expression or plain
134 # string? If it starts with a caret, it's a regexp.
135 if matcher.startswith('^'):
136 cre = re.compile(matcher)
137 # Search against the entire remaining path.
138 tmp_segments = segments[:]
139 tmp_segments.insert(0, this_segment)
140 remaining_path = SLASH.join(tmp_segments)
141 mo = cre.match(remaining_path)
142 if mo:
143 result = attribute(
144 context, segments, **mo.groupdict())
145 elif matcher == this_segment:
146 result = attribute(context, segments)
147 else:
148 # The matcher is a callable. It returns None if it
149 # doesn't match, and if it does, it returns a 3-tuple
150 # containing the positional arguments, the keyword
151 # arguments, and the remaining segments. The attribute is
152 # then called with these arguments. Note that the matcher
153 # wants to see the full remaining path components, which
154 # includes the current hop.
155 tmp_segments = segments[:]
156 tmp_segments.insert(0, this_segment)
157 matcher_result = matcher(tmp_segments)
158 if matcher_result is not None:
159 positional, keyword, segments = matcher_result
160 result = attribute(
161 context, segments, *positional, **keyword)
162 # The attribute could return a 2-tuple giving the resource and
163 # remaining path segments, or it could just return the result.
164 # Of course, if the result is None, then the matcher did not
165 # match.
166 if result is None:
167 continue
168 elif isinstance(result, tuple):
169 resource, segments = result
170 else:
171 resource = result
172 # See if the context set an API and set it on the next
173 # resource in the chain, falling back to the parent resource's
174 # API if there is one.
175 resource.api = context.pop('api', api)
176 # The method could have truncated the remaining segments,
177 # meaning, it's consumed all the path segments, or this is the
178 # last path segment. In that case the resource we're left at
179 # is the responder.
180 if len(segments) == 0:
181 # We're at the end of the path, so the root must be the
182 # responder.
183 method_map = create_http_method_map(resource)
184 return resource, method_map, context
185 this_segment = segments.pop(0)
186 break
187 else:
188 # None of the attributes matched this path component, so the
189 # response is a 404.
190 return None, None, None
193 class RootedAPI(API):
194 def __init__(self, root, *args, **kws):
195 super().__init__(
196 *args,
197 middleware=Middleware(),
198 router=ObjectRouter(root),
199 **kws)
200 # Let Falcon parse the form data into the request object's
201 # .params attribute.
202 self.req_options.auto_parse_form_urlencoded = True
203 # Don't ignore empty query parameters.
204 self.req_options.keep_blank_qs_values = True
206 # Override the base class implementation to wrap a transactional
207 # handler around the call, so that the current transaction is
208 # committed if no errors occur, and aborted otherwise.
209 @transactional
210 def __call__(self, environ, start_response):
211 return super().__call__(environ, start_response)
214 @public
215 def make_application():
216 """Create the WSGI application.
218 Use this if you want to integrate Mailman's REST server with your own WSGI
219 server.
221 return RootedAPI(Root())
224 @public
225 def make_server():
226 """Create the Mailman REST server.
228 Use this if you just want to run Mailman's wsgiref-based REST server.
230 host = config.webservice.hostname
231 port = int(config.webservice.port)
232 server = wsgi_server(
233 host, port, make_application(),
234 server_class=AdminWSGIServer,
235 handler_class=AdminWebServiceWSGIRequestHandler)
236 return server