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