1 # Copyright (C) 2010-2017 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)
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
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."""
23 from base64
import b64decode
24 from falcon
import API
, HTTPUnauthorized
25 from falcon
.routing
import create_http_method_map
26 from mailman
.config
import config
27 from mailman
.database
.transaction
import transactional
28 from mailman
.rest
.root
import Root
29 from public
import public
30 from wsgiref
.simple_server
import (
31 WSGIRequestHandler
, WSGIServer
, make_server
as wsgi_server
)
34 log
= logging
.getLogger('mailman.http')
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',
56 def write(self
, message
):
57 self
._buffer
.append(message
)
60 self
._buffer
.insert(0, 'REST request handler error:\n')
61 log
.error(EMPTYSTRING
.join(self
._buffer
))
65 class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler
):
66 """Handler class which just logs output to the right place."""
68 default_request_version
= 'HTTP/1.1'
70 def log_message(self
, format
, *args
):
71 """See `BaseHTTPRequestHandler`."""
72 log
.info('%s - - %s', self
.address_string(), format
% args
)
75 # Return a fake stderr object that will actually write its output to
81 """Falcon middleware object for Mailman's REST API.
83 This does two things. It sets the API version on the resource
84 object, and it verifies that the proper authentication has been
87 def process_resource(self
, request
, response
, resource
, params
):
88 # Check the authorization credentials.
90 if request
.auth
is not None and request
.auth
.startswith('Basic '):
91 # b64decode() returns bytes, but we require a str.
92 credentials
= b64decode(request
.auth
[6:]).decode('utf-8')
93 username
, password
= credentials
.split(':', 1)
94 if (username
== config
.webservice
.admin_user
and
95 password
== config
.webservice
.admin_pass
):
99 raise HTTPUnauthorized(
101 'REST API authorization failed',
102 challenges
=['Basic realm=Mailman3'])
106 def __init__(self
, root
):
109 def add_route(self
, uri_template
, method_map
, resource
):
110 # We don't need this method for object-based routing.
111 raise NotImplementedError
114 segments
= uri
.split(SLASH
)
115 # Since the path is always rooted at /, skip the first segment, which
116 # will always be the empty string.
118 this_segment
= segments
.pop(0)
119 resource
= self
._root
122 # Plumb the API through to all child resources.
123 api
= getattr(resource
, 'api', None)
124 # See if any of the resource's child links match the next segment.
125 for name
in dir(resource
):
126 if name
.startswith('__') and name
.endswith('__'):
128 attribute
= getattr(resource
, name
, MISSING
)
129 assert attribute
is not MISSING
, name
130 matcher
= getattr(attribute
, '__matcher__', MISSING
)
131 if matcher
is MISSING
:
134 if isinstance(matcher
, str):
135 # Is the matcher string a regular expression or plain
136 # string? If it starts with a caret, it's a regexp.
137 if matcher
.startswith('^'):
138 cre
= re
.compile(matcher
)
139 # Search against the entire remaining path.
140 tmp_segments
= segments
[:]
141 tmp_segments
.insert(0, this_segment
)
142 remaining_path
= SLASH
.join(tmp_segments
)
143 mo
= cre
.match(remaining_path
)
146 context
, segments
, **mo
.groupdict())
147 elif matcher
== this_segment
:
148 result
= attribute(context
, segments
)
150 # The matcher is a callable. It returns None if it
151 # doesn't match, and if it does, it returns a 3-tuple
152 # containing the positional arguments, the keyword
153 # arguments, and the remaining segments. The attribute is
154 # then called with these arguments. Note that the matcher
155 # wants to see the full remaining path components, which
156 # includes the current hop.
157 tmp_segments
= segments
[:]
158 tmp_segments
.insert(0, this_segment
)
159 matcher_result
= matcher(tmp_segments
)
160 if matcher_result
is not None:
161 positional
, keyword
, segments
= matcher_result
163 context
, segments
, *positional
, **keyword
)
164 # The attribute could return a 2-tuple giving the resource and
165 # remaining path segments, or it could just return the result.
166 # Of course, if the result is None, then the matcher did not
170 elif isinstance(result
, tuple):
171 resource
, segments
= result
174 # See if the context set an API and set it on the next
175 # resource in the chain, falling back to the parent resource's
176 # API if there is one.
177 resource
.api
= context
.pop('api', api
)
178 # The method could have truncated the remaining segments,
179 # meaning, it's consumed all the path segments, or this is the
180 # last path segment. In that case the resource we're left at
182 if len(segments
) == 0:
183 # We're at the end of the path, so the root must be the
185 method_map
= create_http_method_map(resource
)
186 return resource
, method_map
, context
187 this_segment
= segments
.pop(0)
190 # None of the attributes matched this path component, so the
192 return None, None, None
195 class RootedAPI(API
):
196 def __init__(self
, root
, *args
, **kws
):
199 middleware
=Middleware(),
200 router
=ObjectRouter(root
),
202 # Let Falcon parse the form data into the request object's
204 self
.req_options
.auto_parse_form_urlencoded
= True
205 # Don't ignore empty query parameters, e.g. preserve empty string
206 # values, which some resources will interpret as a DELETE.
207 self
.req_options
.keep_blank_qs_values
= True
209 # Override the base class implementation to wrap a transactional
210 # handler around the call, so that the current transaction is
211 # committed if no errors occur, and aborted otherwise.
213 def __call__(self
, environ
, start_response
):
214 return super().__call
__(environ
, start_response
)
218 def make_application():
219 """Return a callable WSGI application object."""
220 return RootedAPI(Root())
225 """Create the Mailman REST server.
227 Use this if you just want to run Mailman's wsgiref-based REST server.
229 host
= config
.webservice
.hostname
230 port
= int(config
.webservice
.port
)
231 server
= wsgi_server(
232 host
, port
, make_application(),
233 server_class
=AdminWSGIServer
,
234 handler_class
=AdminWebServiceWSGIRequestHandler
)