# Copyright (C) 2010-2017 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 . """REST for members.""" from lazr.config import as_boolean from mailman.app.membership import add_member, delete_member from mailman.interfaces.action import Action from mailman.interfaces.address import IAddress from mailman.interfaces.listmanager import IListManager from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError, MembershipIsBannedError, MissingPreferredAddressError) from mailman.interfaces.subscriptions import ( ISubscriptionManager, ISubscriptionService, RequestRecord, SubscriptionPendingError, TokenOwner) from mailman.interfaces.user import IUser, UnverifiedAddressError from mailman.interfaces.usermanager import IUserManager from mailman.rest.helpers import ( CollectionMixin, NotFound, accepted, bad_request, child, conflict, created, etag, no_content, not_found, okay) from mailman.rest.preferences import Preferences, ReadOnlyPreferences from mailman.rest.validator import ( Validator, enum_validator, subscriber_validator) from public import public from uuid import UUID from zope.component import getUtility class _MemberBase(CollectionMixin): """Shared base class for member representations.""" def _resource_as_dict(self, member): """See `CollectionMixin`.""" enum, dot, role = str(member.role).partition('.') # The member will always have a member id and an address id. It will # only have a user id if the address is linked to a user. # E.g. nonmembers we've only seen via postings to lists they are not # subscribed to will not have a user id. The user_id and the # member_id are UUIDs. In API 3.0 we use the integer equivalent of # the UID in the URL, but in API 3.1 we use the hex equivalent. See # issue #121 for details. member_id = self.api.from_uuid(member.member_id) response = dict( address=self.api.path_to( 'addresses/{}'.format(member.address.email)), delivery_mode=member.delivery_mode, email=member.address.email, list_id=member.list_id, member_id=member_id, role=role, self_link=self.api.path_to('members/{}'.format(member_id)), ) # Add the moderation action if overriding the list's default. if member.moderation_action is not None: response['moderation_action'] = member.moderation_action # Add the user link if there is one. user = member.user if user is not None: user_id = self.api.from_uuid(user.user_id) response['user'] = self.api.path_to('users/{}'.format(user_id)) return response def _get_collection(self, request): """See `CollectionMixin`.""" return list(getUtility(ISubscriptionService)) @public class MemberCollection(_MemberBase): """Abstract class for supporting submemberships. This is used for example to return a resource representing all the memberships of a mailing list, or all memberships for a specific email address. """ def _get_collection(self, request): """See `CollectionMixin`.""" raise NotImplementedError def on_get(self, request, response): """roster/[members|owners|moderators]""" resource = self._make_collection(request) okay(response, etag(resource)) @public class AMember(_MemberBase): """A member.""" def __init__(self, member_id): # The member_id is either the member's UUID or the string # representation of the member's UUID. service = getUtility(ISubscriptionService) self._member_id = member_id self._member = (None if member_id is None else service.get_member(member_id)) def on_get(self, request, response): """Return a single member end-point.""" if self._member is None: not_found(response) else: okay(response, self._resource_as_json(self._member)) @child() def preferences(self, context, segments): """/members//preferences""" if len(segments) != 0: return NotFound(), [] if self._member is None: return NotFound(), [] member_id = self.api.from_uuid(self._member_id) child = Preferences( self._member.preferences, 'members/{}'.format(member_id)) return child, [] @child() def all(self, context, segments): """/members//all/preferences""" if len(segments) == 0: return NotFound(), [] if self._member is None: return NotFound(), [] member_id = self.api.from_uuid(self._member_id) child = ReadOnlyPreferences( self._member, 'members/{}/all'.format(member_id)) return child, [] def on_delete(self, request, response): """Delete the member (i.e. unsubscribe).""" # Leaving a list is a bit different than deleting a moderator or # owner. Handle the former case first. For now too, we will not send # an admin or user notification. if self._member is None: not_found(response) return mlist = getUtility(IListManager).get_by_list_id(self._member.list_id) if self._member.role is MemberRole.member: delete_member(mlist, self._member.address.email, False, False) else: self._member.unsubscribe() no_content(response) def on_patch(self, request, response): """Patch the membership. This is how subscription changes are done. """ if self._member is None: not_found(response) return try: values = Validator( address=str, delivery_mode=enum_validator(DeliveryMode), moderation_action=enum_validator(Action, allow_blank=True), _optional=('address', 'delivery_mode', 'moderation_action'), )(request) except ValueError as error: bad_request(response, str(error)) return if 'address' in values: email = values['address'] address = getUtility(IUserManager).get_address(email) if address is None: bad_request(response, b'Address not registered') return try: self._member.address = address except (MembershipError, UnverifiedAddressError) as error: bad_request(response, str(error)) return if 'delivery_mode' in values: self._member.preferences.delivery_mode = values['delivery_mode'] if 'moderation_action' in values: self._member.moderation_action = values['moderation_action'] no_content(response) @public class AllMembers(_MemberBase): """The members.""" def on_post(self, request, response): """Create a new member.""" try: validator = Validator( list_id=str, subscriber=subscriber_validator(self.api), display_name=str, delivery_mode=enum_validator(DeliveryMode), role=enum_validator(MemberRole), pre_verified=as_boolean, pre_confirmed=as_boolean, pre_approved=as_boolean, _optional=('delivery_mode', 'display_name', 'role', 'pre_verified', 'pre_confirmed', 'pre_approved')) arguments = validator(request) except ValueError as error: bad_request(response, str(error)) return # Dig the mailing list out of the arguments. list_id = arguments.pop('list_id') mlist = getUtility(IListManager).get_by_list_id(list_id) if mlist is None: bad_request(response, b'No such list') return # Figure out what kind of subscriber is being registered. Either it's # a user via their preferred email address or it's an explicit address. # If it's a UUID, then it must be associated with an existing user. subscriber = arguments.pop('subscriber') user_manager = getUtility(IUserManager) # We use the display name if there is one. display_name = arguments.pop('display_name', '') if isinstance(subscriber, UUID): user = user_manager.get_user_by_id(subscriber) if user is None: bad_request(response, b'No such user') return subscriber = user else: # This must be an email address. See if there's an existing # address object associated with this email. address = user_manager.get_address(subscriber) if address is None: # Create a new address, which of course will not be validated. address = user_manager.create_address( subscriber, display_name) subscriber = address # What role are we subscribing? Regular members go through the # subscription policy workflow while owners, moderators, and # nonmembers go through the legacy API for now. role = arguments.pop('role', MemberRole.member) if role is MemberRole.member: # Get the pre_ flags for the subscription workflow. pre_verified = arguments.pop('pre_verified', False) pre_confirmed = arguments.pop('pre_confirmed', False) pre_approved = arguments.pop('pre_approved', False) # Now we can run the registration process until either the # subscriber is subscribed, or the workflow is paused for # verification, confirmation, or approval. registrar = ISubscriptionManager(mlist) try: token, token_owner, member = registrar.register( subscriber, pre_verified=pre_verified, pre_confirmed=pre_confirmed, pre_approved=pre_approved) except AlreadySubscribedError: conflict(response, b'Member already subscribed') return except MissingPreferredAddressError: bad_request(response, b'User has no preferred address') return except MembershipIsBannedError: bad_request(response, b'Membership is banned') return except SubscriptionPendingError: conflict(response, b'Subscription request already pending') return if token is None: assert token_owner is TokenOwner.no_one, token_owner # The subscription completed. Let's get the resulting member # and return the location to the new member. Member ids are # UUIDs and need to be converted to URLs because JSON doesn't # directly support UUIDs. member_id = self.api.from_uuid(member.member_id) location = self.api.path_to('members/{}'.format(member_id)) created(response, location) return # The member could not be directly subscribed because there are # some out-of-band steps that need to be completed. E.g. the user # must confirm their subscription or the moderator must approve # it. In this case, an HTTP 202 Accepted is exactly the code that # we should use, and we'll return both the confirmation token and # the "token owner" so the client knows who should confirm it. assert token is not None, token assert token_owner is not TokenOwner.no_one, token_owner assert member is None, member content = dict(token=token, token_owner=token_owner.name) accepted(response, etag(content)) return # 2015-04-15 BAW: We're subscribing some role other than a regular # member. Use the legacy API for this for now. assert role in (MemberRole.owner, MemberRole.moderator, MemberRole.nonmember) # 2015-04-15 BAW: We're limited to using an email address with this # legacy API, so if the subscriber is a user, the user must have a # preferred address, which we'll use, even though it will subscribe # the explicit address. It is an error if the user does not have a # preferred address. # # If the subscriber is an address object, just use that. if IUser.providedBy(subscriber): if subscriber.preferred_address is None: bad_request(response, b'User without preferred address') return email = subscriber.preferred_address.email else: assert IAddress.providedBy(subscriber) email = subscriber.email delivery_mode = arguments.pop('delivery_mode', DeliveryMode.regular) record = RequestRecord(email, display_name, delivery_mode) try: member = add_member(mlist, record, role) except MembershipIsBannedError: bad_request(response, b'Membership is banned') return except AlreadySubscribedError: bad_request(response, '{} is already an {} of {}'.format( email, role.name, mlist.fqdn_listname)) return # The subscription completed. Let's get the resulting member # and return the location to the new member. Member ids are # UUIDs and need to be converted to URLs because JSON doesn't # directly support UUIDs. member_id = self.api.from_uuid(member.member_id) location = self.api.path_to('members/{}'.format(member_id)) created(response, location) def on_get(self, request, response): """/members""" resource = self._make_collection(request) okay(response, etag(resource)) class _FoundMembers(MemberCollection): """The found members collection.""" def __init__(self, members, api): super().__init__() self._members = members self.api = api def _get_collection(self, request): """See `CollectionMixin`.""" return self._members @public class FindMembers(_MemberBase): """/members/find""" def on_get(self, request, response): return self._find(request, response) def on_post(self, request, response): return self._find(request, response) def _find(self, request, response): """Find a member""" service = getUtility(ISubscriptionService) validator = Validator( list_id=str, subscriber=str, role=enum_validator(MemberRole), # Allow pagination. page=int, count=int, _optional=('list_id', 'subscriber', 'role', 'page', 'count')) try: data = validator(request) except ValueError as error: bad_request(response, str(error)) else: # Remove any optional pagination query elements; they will be # handled later. data.pop('page', None) data.pop('count', None) members = service.find_members(**data) resource = _FoundMembers(members, self.api) okay(response, etag(resource._make_collection(request)))