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"""REST for mailing lists."""
19
20from lazr.config import as_boolean
21from mailman.app.digests import (
22    bump_digest_number_and_volume, maybe_send_digest_now)
23from mailman.app.lifecycle import (
24    InvalidListNameError, create_list, remove_list)
25from mailman.config import config
26from mailman.interfaces.address import InvalidEmailAddressError
27from mailman.interfaces.domain import BadDomainSpecificationError
28from mailman.interfaces.listmanager import (
29    IListManager, ListAlreadyExistsError)
30from mailman.interfaces.mailinglist import IListArchiverSet
31from mailman.interfaces.member import MemberRole
32from mailman.interfaces.styles import IStyleManager
33from mailman.interfaces.subscriptions import ISubscriptionService
34from mailman.rest.bans import BannedEmails
35from mailman.rest.header_matches import HeaderMatches
36from mailman.rest.helpers import (
37    BadRequest, CollectionMixin, GetterSetter, NotFound, accepted,
38    bad_request, child, created, etag, no_content, not_found, okay)
39from mailman.rest.listconf import ListConfiguration
40from mailman.rest.members import AMember, MemberCollection
41from mailman.rest.post_moderation import HeldMessages
42from mailman.rest.sub_moderation import SubscriptionRequests
43from mailman.rest.uris import AListURI, AllListURIs
44from mailman.rest.validator import (
45    Validator, enum_validator, list_of_strings_validator, subscriber_validator)
46from public import public
47from zope.component import getUtility
48
49
50def member_matcher(segments):
51    """A matcher of member URLs inside mailing lists.
52
53    e.g. /<role>/aperson@example.org
54    """
55    if len(segments) != 2:
56        return None
57    try:
58        role = MemberRole[segments[0]]
59    except KeyError:
60        # Not a valid role.
61        return None
62    return (), dict(role=role, email=segments[1]), ()
63
64
65def roster_matcher(segments):
66    """A matcher of all members URLs inside mailing lists.
67
68    e.g. /roster/<role>
69    """
70    if len(segments) != 2 or segments[0] != 'roster':
71        return None
72    try:
73        return (), dict(role=MemberRole[segments[1]]), ()
74    except KeyError:
75        # Not a valid role.
76        return None
77
78
79def config_matcher(segments):
80    """A matcher for a mailing list's configuration resource.
81
82    e.g. /config
83    e.g. /config/description
84    """
85    if len(segments) < 1 or segments[0] != 'config':
86        return None
87    if len(segments) == 1:
88        return (), {}, ()
89    if len(segments) == 2:
90        return (), dict(attribute=segments[1]), ()
91    # More segments are not allowed.
92    return None
93
94
95class _ListBase(CollectionMixin):
96    """Shared base class for mailing list representations."""
97
98    def _resource_as_dict(self, mlist):
99        """See `CollectionMixin`."""
100        return dict(
101            advertised=mlist.advertised,
102            display_name=mlist.display_name,
103            fqdn_listname=mlist.fqdn_listname,
104            list_id=mlist.list_id,
105            list_name=mlist.list_name,
106            mail_host=mlist.mail_host,
107            member_count=mlist.members.member_count,
108            volume=mlist.volume,
109            description=mlist.description,
110            self_link=self.api.path_to('lists/{}'.format(mlist.list_id)),
111            )
112
113    def _get_collection(self, request):
114        """See `CollectionMixin`."""
115        return self._filter_lists(request)
116
117    def _filter_lists(self, request, **kw):
118        """Filter a collection using query parameters."""
119        advertised = request.get_param_as_bool('advertised')
120        if advertised:
121            kw['advertised'] = True
122        return getUtility(IListManager).find(**kw)
123
124
125class _ListOfLists(_ListBase):
126    """An abstract class to return a sub-set of Lists.
127
128    This is used for filtering Lists based on some parameters.
129    """
130    def __init__(self, lists, api):
131        super().__init__()
132        self._lists = lists
133        self.api = api
134
135    def _get_collection(self, request):
136        return self._lists
137
138
139@public
140class FindLists(_ListBase):
141    """The mailing lists that a user is a member of."""
142
143    def on_get(self, request, response):
144        return self._find(request, response)
145
146    def on_post(self, request, response):
147        return self._find(request, response)
148
149    def _find(self, request, response):
150        validator = Validator(
151            subscriber=subscriber_validator(self.api),
152            role=enum_validator(MemberRole),
153            # Allow pagination.
154            page=int,
155            count=int,
156            _optional=('role', 'page', 'count'))
157        try:
158            data = validator(request)
159        except ValueError as error:
160            bad_request(response, str(error))
161            return
162        else:
163            # Remove any optional pagination query elements.
164            data.pop('page', None)
165            data.pop('count', None)
166            service = getUtility(ISubscriptionService)
167            # Get all membership records for given subscriber.
168            memberships = service.find_members(**data)
169            # Get all the lists from from the membership records.
170            lists = [getUtility(IListManager).get_by_list_id(member.list_id)
171                     for member in memberships]
172            # If there are no matching lists, return a 404.
173            if not len(lists):
174                return not_found(response)
175            resource = _ListOfLists(lists, self.api)
176            okay(response, etag(resource._make_collection(request)))
177
178
179@public
180class AList(_ListBase):
181    """A mailing list."""
182
183    def __init__(self, list_identifier):
184        # list-id is preferred, but for backward compatibility, fqdn_listname
185        # is also accepted.  If the string contains '@', treat it as the
186        # latter.
187        manager = getUtility(IListManager)
188        if '@' in list_identifier:
189            self._mlist = manager.get(list_identifier)
190        else:
191            self._mlist = manager.get_by_list_id(list_identifier)
192
193    def on_get(self, request, response):
194        """Return a single mailing list end-point."""
195        if self._mlist is None:
196            not_found(response)
197        else:
198            okay(response, self._resource_as_json(self._mlist))
199
200    def on_delete(self, request, response):
201        """Delete the named mailing list."""
202        if self._mlist is None:
203            not_found(response)
204        else:
205            remove_list(self._mlist)
206            no_content(response)
207
208    @child(member_matcher)
209    def member(self, context, segments, role, email):
210        """Return a single member representation."""
211        if self._mlist is None:
212            return NotFound(), []
213        member = getUtility(ISubscriptionService).find_member(
214            email, self._mlist.list_id, role)
215        if member is None:
216            return NotFound(), []
217        return AMember(member.member_id)
218
219    @child(roster_matcher)
220    def roster(self, context, segments, role):
221        """Return the collection of all a mailing list's members."""
222        if self._mlist is None:
223            return NotFound(), []
224        return MembersOfList(self._mlist, role)
225
226    @child(config_matcher)
227    def config(self, context, segments, attribute=None):
228        """Return a mailing list configuration object."""
229        if self._mlist is None:
230            return NotFound(), []
231        return ListConfiguration(self._mlist, attribute)
232
233    @child()
234    def held(self, context, segments):
235        """Return a list of held messages for the mailing list."""
236        if self._mlist is None:
237            return NotFound(), []
238        return HeldMessages(self._mlist)
239
240    @child()
241    def requests(self, context, segments):
242        """Return a list of subscription/unsubscription requests."""
243        if self._mlist is None:
244            return NotFound(), []
245        return SubscriptionRequests(self._mlist)
246
247    @child()
248    def archivers(self, context, segments):
249        """Return a representation of mailing list archivers."""
250        if self._mlist is None:
251            return NotFound(), []
252        return ListArchivers(self._mlist)
253
254    @child()
255    def digest(self, context, segments):
256        if self._mlist is None:
257            return NotFound(), []
258        return ListDigest(self._mlist)
259
260    @child()
261    def bans(self, context, segments):
262        """Return a collection of mailing list's banned addresses."""
263        if self._mlist is None:
264            return NotFound(), []
265        return BannedEmails(self._mlist)
266
267    @child(r'^header-matches')
268    def header_matches(self, context, segments):
269        """Return a collection of mailing list's header matches."""
270        if self._mlist is None:
271            return NotFound(), []
272        return HeaderMatches(self._mlist)
273
274    @child()
275    def uris(self, context, segments):
276        """Return the template URIs of the mailing list.
277
278        These are only available after API 3.0.
279        """
280        if self._mlist is None or self.api.version_info < (3, 1):
281            return NotFound(), []
282        if len(segments) == 0:
283            return AllListURIs(self._mlist)
284        if len(segments) > 1:
285            return BadRequest(), []
286        template = segments[0]
287        if template not in AllListURIs.URIs:
288            return NotFound(), []
289        return AListURI(self._mlist, template), []
290
291
292@public
293class AllLists(_ListBase):
294    """The mailing lists."""
295
296    def on_post(self, request, response):
297        """Create a new mailing list."""
298        try:
299            validator = Validator(fqdn_listname=str,
300                                  style_name=str,
301                                  _optional=('style_name',))
302            mlist = create_list(**validator(request))
303        except ValueError as error:
304            bad_request(response, str(error))
305        except ListAlreadyExistsError:
306            bad_request(response, b'Mailing list exists')
307        except BadDomainSpecificationError as error:
308            reason = 'Domain does not exist: {}'.format(error.domain)
309            bad_request(response, reason.encode('utf-8'))
310        except InvalidListNameError as error:
311            reason = 'Invalid list name: {}'.format(error.listname)
312            bad_request(response, reason.encode('utf-8'))
313        except InvalidEmailAddressError as error:
314            reason = 'Invalid list posting address: {}'.format(error.email)
315            bad_request(response, reason.encode('utf-8'))
316        else:
317            location = self.api.path_to('lists/{0}'.format(mlist.list_id))
318            created(response, location)
319
320    def on_get(self, request, response):
321        """/lists"""
322        resource = self._make_collection(request)
323        okay(response, etag(resource))
324
325
326@public
327class MembersOfList(MemberCollection):
328    """The members of a mailing list."""
329
330    def __init__(self, mailing_list, role):
331        super().__init__()
332        self._mlist = mailing_list
333        self._role = role
334
335    def _get_collection(self, request):
336        """See `CollectionMixin`."""
337        # Overrides _MemberBase._get_collection() because we only want to
338        # return the members from the contexted roster.
339        return getUtility(ISubscriptionService).find_members(
340            list_id=self._mlist.list_id,
341            role=self._role)
342
343    def on_delete(self, request, response):
344        """Delete the members of the named mailing list."""
345        status = {}
346        try:
347            validator = Validator(emails=list_of_strings_validator)
348            arguments = validator(request)
349        except ValueError as error:
350            bad_request(response, str(error))
351            return
352        emails = arguments.pop('emails')
353        success, fail = getUtility(ISubscriptionService).unsubscribe_members(
354            self._mlist.list_id, emails)
355        # There should be no email in both sets.
356        assert success.isdisjoint(fail), (success, fail)
357        status.update({email: True for email in success})
358        status.update({email: False for email in fail})
359        okay(response, etag(status))
360
361
362@public
363class ListsForDomain(_ListBase):
364    """The mailing lists for a particular domain."""
365
366    def __init__(self, domain):
367        self._domain = domain
368
369    def on_get(self, request, response):
370        """/domains/<domain>/lists"""
371        resource = self._make_collection(request)
372        okay(response, etag(resource))
373
374    def _get_collection(self, request):
375        """See `CollectionMixin`."""
376        return self._filter_lists(request, mail_host=self._domain.mail_host)
377
378
379@public
380class ArchiverGetterSetter(GetterSetter):
381    """Resource for updating archiver statuses."""
382
383    def __init__(self, mlist):
384        super().__init__()
385        self._archiver_set = IListArchiverSet(mlist)
386
387    def put(self, mlist, attribute, value):
388        # attribute will contain the (bytes) name of the archiver that is
389        # getting a new status.  value will be the representation of the new
390        # boolean status.
391        archiver = self._archiver_set.get(attribute)
392        assert archiver is not None, attribute
393        archiver.is_enabled = as_boolean(value)
394
395
396@public
397class ListArchivers:
398    """The archivers for a list, with their enabled flags."""
399
400    def __init__(self, mlist):
401        self._mlist = mlist
402
403    def on_get(self, request, response):
404        """Get all the archiver statuses."""
405        archiver_set = IListArchiverSet(self._mlist)
406        resource = {archiver.name: archiver.is_enabled
407                    for archiver in archiver_set.archivers
408                    if archiver.system_archiver.is_enabled}
409        okay(response, etag(resource))
410
411    def patch_put(self, request, response, is_optional):
412        archiver_set = IListArchiverSet(self._mlist)
413        kws = {archiver.name: ArchiverGetterSetter(self._mlist)
414               for archiver in archiver_set.archivers
415               if archiver.system_archiver.is_enabled}
416        if is_optional:
417            # For a PATCH, all attributes are optional.
418            kws['_optional'] = kws.keys()
419        try:
420            Validator(**kws).update(self._mlist, request)
421        except ValueError as error:
422            bad_request(response, str(error))
423        else:
424            no_content(response)
425
426    def on_put(self, request, response):
427        """Update all the archiver statuses."""
428        self.patch_put(request, response, is_optional=False)
429
430    def on_patch(self, request, response):
431        """Patch some archiver statueses."""
432        self.patch_put(request, response, is_optional=True)
433
434
435@public
436class ListDigest:
437    """Simple resource representing actions on a list's digest."""
438
439    def __init__(self, mlist):
440        self._mlist = mlist
441
442    def on_get(self, request, response):
443        resource = dict(
444            next_digest_number=self._mlist.next_digest_number,
445            volume=self._mlist.volume,
446            )
447        okay(response, etag(resource))
448
449    def on_post(self, request, response):
450        try:
451            validator = Validator(
452                send=as_boolean,
453                bump=as_boolean,
454                periodic=as_boolean,
455                _optional=('send', 'bump', 'periodic'))
456            values = validator(request)
457        except ValueError as error:
458            bad_request(response, str(error))
459            return
460        if values.get('send', False) and values.get('periodic', False):
461            # Send and periodic and mutually exclusive options.
462            bad_request(
463                response, 'send and periodic options are mutually exclusive')
464            return
465        if len(values) == 0:
466            # There's nothing to do, but that's okay.
467            okay(response)
468            return
469        if values.get('bump', False):
470            bump_digest_number_and_volume(self._mlist)
471        if values.get('send', False):
472            maybe_send_digest_now(self._mlist, force=True)
473        if values.get('periodic', False) and self._mlist.digest_send_periodic:
474            maybe_send_digest_now(self._mlist, force=True)
475        accepted(response)
476
477
478@public
479class Styles:
480    """Simple resource representing all list styles."""
481
482    def __init__(self):
483        manager = getUtility(IStyleManager)
484        styles = [dict(name=style.name, description=style.description)
485                  for style in manager.styles]
486        style_names = sorted(style.name for style in manager.styles)
487        self._resource = dict(
488            # TODO (maxking): style_name is meant for backwards compatibility
489            # and should be removed in 3.3 release.
490            style_names=style_names,
491            styles=styles,
492            default=config.styles.default)
493
494    def on_get(self, request, response):
495        okay(response, etag(self._resource))
496