1# Copyright (C) 2010-2020 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 <https://www.gnu.org/licenses/>.
17
18"""The root of the REST API."""
19
20from mailman.config import config
21from mailman.core.api import API30, API31
22from mailman.core.constants import system_preferences
23from mailman.core.system import system
24from mailman.interfaces.listmanager import IListManager
25from mailman.model.uid import UID
26from mailman.rest.addresses import AllAddresses, AnAddress
27from mailman.rest.bans import BannedEmail, BannedEmails
28from mailman.rest.domains import ADomain, AllDomains
29from mailman.rest.helpers import (
30    BadRequest, NotFound, child, etag, no_content, not_found, okay)
31from mailman.rest.lists import AList, AllLists, FindLists, Styles
32from mailman.rest.members import AMember, AllMembers, FindMembers
33from mailman.rest.plugins import APlugin, AllPlugins
34from mailman.rest.preferences import ReadOnlyPreferences
35from mailman.rest.queues import AQueue, AQueueFile, AllQueues
36from mailman.rest.templates import TemplateFinder
37from mailman.rest.uris import ASiteURI, AllSiteURIs
38from mailman.rest.users import AUser, AllUsers, ServerOwners
39from public import public
40from zope.component import getUtility
41
42
43SLASH = '/'
44
45
46@public
47class Root:
48    """The RESTful root resource.
49
50    At the root of the tree are the API version numbers.  Everything else
51    lives underneath those.  Currently there is only one API version number,
52    and we start at 3.0 to match the Mailman version number.  That may not
53    always be the case though.
54    """
55
56    @child('3.0')
57    def api_version_30(self, context, segments):
58        # API version 3.0 was introduced in Mailman 3.0.
59        context['api'] = API30
60        return TopLevel()
61
62    @child('3.1')
63    def api_version_31(self, context, segments):
64        # API version 3.1 was introduced in Mailman 3.1.  Primary backward
65        # incompatible difference is that uuids are represented as hex strings
66        # instead of 128 bit integers.  The latter is not compatible with all
67        # versions of JavaScript.
68        context['api'] = API31
69        return TopLevel()
70
71
72@public
73class Versions:
74    def on_get(self, request, response):
75        """/<api>/system/versions"""
76        resource = dict(
77            mailman_version=system.mailman_version,
78            python_version=system.python_version,
79            api_version=self.api.version,
80            self_link=self.api.path_to('system/versions'),
81            )
82        okay(response, etag(resource))
83
84
85@public
86class SystemConfiguration:
87    def __init__(self, section=None):
88        self._section = section
89
90    def on_get(self, request, response):
91        if self._section is None:
92            resource = dict(
93                sections=sorted(section.name for section in config),
94                self_link=self.api.path_to('system/configuration'),
95                )
96            okay(response, etag(resource))
97            return
98        missing = object()
99        section = getattr(config, self._section, missing)
100        if section is missing:
101            not_found(response)
102            return
103        # Sections don't have .keys(), .values(), or .items() but we can
104        # iterate over them.
105        resource = {key: section[key] for key in section}
106        # Add a `self_link` attribute to the resource.  This is a little ugly
107        # because technically speaking we're mixing namespaces.  We can't have
108        # a variable named `self_link` in any section, but also we can't have
109        # `http_etag` either, so unless we want to shove all these values into
110        # a sub dictionary (which we don't), we have to live with it.
111        self_link = self.api.path_to(
112            'system/configuration/{}'.format(section.name))
113        resource['self_link'] = self_link
114        okay(response, etag(resource))
115
116
117@public
118class Pipelines:
119    def on_get(self, request, response):
120        resource = dict(pipelines=sorted(config.pipelines))
121        okay(response, etag(resource))
122
123
124@public
125class Chains:
126    def on_get(self, request, response):
127        resource = dict(chains=sorted(config.chains))
128        okay(response, etag(resource))
129
130
131@public
132class Reserved:
133    """Top level API for reserved operations.
134
135    Nothing under this resource should be considered part of the stable API.
136    The resources that appear here are purely for the support of external
137    non-production systems, such as testing infrastructures for cooperating
138    components.  Use at your own risk.
139    """
140    def __init__(self, segments):
141        self._resource_path = SLASH.join(segments)
142
143    def on_delete(self, request, response):
144        if self._resource_path != 'uids/orphans':
145            not_found(response)
146            return
147        UID.cull_orphans()
148        no_content(response)
149
150
151@public
152class TopLevel:
153    """Top level collections and entries."""
154
155    @child()
156    def system(self, context, segments):
157        """/<api>/system"""
158        if len(segments) == 0:
159            # This provides backward compatibility; see /system/versions.
160            return Versions()
161        elif segments[0] == 'preferences':
162            if len(segments) > 1:
163                return BadRequest(), []
164            return ReadOnlyPreferences(system_preferences, 'system'), []
165        elif segments[0] == 'versions':
166            if len(segments) > 1:
167                return BadRequest(), []
168            return Versions(), []
169        elif segments[0] == 'configuration':
170            if len(segments) <= 2:
171                return SystemConfiguration(*segments[1:]), []
172            return BadRequest(), []
173        elif segments[0] == 'pipelines':
174            if len(segments) > 1:
175                return BadRequest(), []
176            return Pipelines(), []
177        elif segments[0] == 'chains':
178            if len(segments) > 1:
179                return BadRequest(), []
180            return Chains(), []
181        else:
182            return NotFound(), []
183
184    @child()
185    def addresses(self, context, segments):
186        """/<api>/addresses
187           /<api>/addresses/<email>
188        """
189        if len(segments) == 0:
190            return AllAddresses()
191        else:
192            email = segments.pop(0)
193            return AnAddress(email), segments
194
195    @child()
196    def domains(self, context, segments):
197        """/<api>/domains
198           /<api>/domains/<domain>
199        """
200        if len(segments) == 0:
201            return AllDomains()
202        else:
203            domain = segments.pop(0)
204            return ADomain(domain), segments
205
206    @child()
207    def lists(self, context, segments):
208        """/<api>/lists
209           /<api>/lists/styles
210           /<api>/lists/<list>
211           /<api>/lists/<list>/...
212        """
213        if len(segments) == 0:
214            return AllLists()
215        # This does not prevent a mailing list being created with a short name
216        # 'styles', since list identifiers (see below) must either be a
217        # List-Id like styles.example.com, or an fqdn_listname like
218        # styles@example.com.
219        elif len(segments) == 1 and segments[0] == 'styles':
220            return Styles(), []
221        elif len(segments) == 1 and segments[0] == 'find':
222            return FindLists(), []
223        else:
224            # list-id is preferred, but for backward compatibility,
225            # fqdn_listname is also accepted.
226            list_identifier = segments.pop(0)
227            return AList(list_identifier), segments
228
229    @child()
230    def members(self, context, segments):
231        """/<api>/members"""
232        if len(segments) == 0:
233            return AllMembers()
234        # Either the next segment is the string "find" or a member id.  They
235        # cannot collide.
236        segment = segments.pop(0)
237        if segment == 'find':
238            resource = FindMembers()
239        else:
240            try:
241                member_id = self.api.to_uuid(segment)
242            except ValueError:
243                member_id = None
244            resource = AMember(member_id)
245        return resource, segments
246
247    @child()
248    def users(self, context, segments):
249        """/<api>/users"""
250        if len(segments) == 0:
251            return AllUsers()
252        else:
253            user_identifier = segments.pop(0)
254            return AUser(user_identifier), segments
255
256    @child()
257    def owners(self, context, segments):
258        """/<api>/owners"""
259        if len(segments) != 0:
260            return BadRequest(), []
261        else:
262            return ServerOwners(), segments
263
264    @child()
265    def templates(self, context, segments):
266        """/<api>/templates/<fqdn_listname>/<template>/[<language>]
267
268        Use content negotiation to context language and suffix (content-type).
269        """
270        # This resource is removed in API 3.1; use the /uris resource instead.
271        if self.api.version_info > (3, 0):
272            return NotFound(), []
273        if len(segments) == 3:
274            fqdn_listname, template, language = segments
275        elif len(segments) == 2:
276            fqdn_listname, template = segments
277            language = 'en'
278        else:
279            return BadRequest(), []
280        mlist = getUtility(IListManager).get(fqdn_listname)
281        if mlist is None:
282            return NotFound(), []
283        # XXX dig out content-type from context.
284        content_type = None
285        return TemplateFinder(
286            fqdn_listname, template, language, content_type)
287
288    @child()
289    def uris(self, content, segments):
290        if self.api.version_info < (3, 1):
291            return NotFound(), []
292        if len(segments) == 0:
293            return AllSiteURIs()
294        if len(segments) > 1:
295            return BadRequest(), []
296        template = segments[0]
297        if template not in AllSiteURIs.URIs:
298            return NotFound(), []
299        return ASiteURI(template), []
300
301    @child()
302    def queues(self, context, segments):
303        """/<api>/queues[/<name>[/file]]"""
304        if len(segments) == 0:
305            return AllQueues()
306        elif len(segments) == 1:
307            return AQueue(segments[0]), []
308        elif len(segments) == 2:
309            return AQueueFile(segments[0], segments[1]), []
310        else:
311            return BadRequest(), []
312
313    @child()
314    def plugins(self, context, segments):
315        """/<api>/plugins
316           /<api>/plugins/<plugin_name>
317           /<api>/plugins/<plugin_name>/...
318        """
319        if self.api.version_info < (3, 1):
320            return NotFound(), []
321        if len(segments) == 0:
322            return AllPlugins(), []
323        else:
324            plugin_name = segments.pop(0)
325            return APlugin(plugin_name), segments
326
327    @child()
328    def bans(self, context, segments):
329        """/<api>/bans
330           /<api>/bans/<email>
331        """
332        if len(segments) == 0:
333            return BannedEmails(None)
334        else:
335            email = segments.pop(0)
336            return BannedEmail(None, email), segments
337
338    @child()
339    def reserved(self, context, segments):
340        """/<api>/reserved/[...]"""
341        return Reserved(segments), []
342