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