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 web form validation."""
19
20import re
21
22from mailman.interfaces.address import IEmailValidator
23from mailman.interfaces.errors import MailmanError
24from mailman.interfaces.languages import ILanguageManager
25from mailman.rest.helpers import get_request_params
26from public import public
27from zope.component import getUtility
28
29
30COMMASPACE = ', '
31
32
33@public
34class RESTError(MailmanError):
35    """Base class for REST API errors."""
36
37
38@public
39class UnknownPATCHRequestError(RESTError):
40    """A PATCH request contained an unknown attribute."""
41
42    def __init__(self, attribute):
43        self.attribute = attribute
44
45
46@public
47class ReadOnlyPATCHRequestError(RESTError):
48    """A PATCH request contained a read-only attribute."""
49
50    def __init__(self, attribute):
51        self.attribute = attribute
52
53
54@public
55class enum_validator:
56    """Convert an enum value name into an enum value."""
57
58    def __init__(self, enum_class, *, allow_blank=False):
59        self._enum_class = enum_class
60        self._allow_blank = allow_blank
61
62    def __call__(self, enum_value):
63        # This will raise a KeyError if the enum value is unknown.  The
64        # Validator API requires turning this into a ValueError.
65        if not enum_value and self._allow_blank:
66            return None
67        try:
68            return self._enum_class[enum_value]
69        except KeyError:
70            # Retain the error message.
71            err_msg = 'Accepted Values are: {}'.format(self._accepted_values)
72            raise ValueError(err_msg)
73
74    @property
75    def _accepted_values(self):
76        """Joined comma separated self._enum_class values"""
77        return ', '.join(item._name_ for item in self._enum_class)
78
79
80@public
81def subscriber_validator(api):
82    """Convert an email-or-(int|hex) to an email-or-UUID."""
83    def _inner(subscriber):
84        try:
85            return api.to_uuid(subscriber)
86        except ValueError:
87            # It must be an email address.
88            if getUtility(IEmailValidator).is_valid(subscriber):
89                return subscriber
90            raise ValueError
91    return _inner
92
93
94@public
95def language_validator(code):
96    """Convert a language code to a Language object."""
97    return getUtility(ILanguageManager)[code]
98
99
100@public
101def list_of_strings_validator(values):
102    """Turn a list of things, or a single thing, into a list of unicodes."""
103    # There is no good way to pass around an empty list through HTTP API, so,
104    # we consider an empty string as an empty list, which can easily be passed
105    # around. This is a contract between Core and Postorius. This also fixes a
106    # bug where an empty string ('') would be interpreted as a valid value ['']
107    # to create a singleton list, instead of empty list, which in later stages
108    # would create other problems.
109    if values is '':  # noqa: F632
110        return []
111    if not isinstance(values, (list, tuple)):
112        values = [values]
113    for value in values:
114        if not isinstance(value, str):
115            raise ValueError('Expected str, got {!r}'.format(value))
116    return values
117
118
119@public
120def list_of_emails_validator(values):
121    """Turn a list of things, or a single thing, into a list of emails."""
122    if not isinstance(values, (list, tuple)):
123        if getUtility(IEmailValidator).is_valid(values):
124            return [values]
125        raise ValueError('Bad email address format: {}'.format(values))
126    for value in values:
127        if not getUtility(IEmailValidator).is_valid(value):
128            raise ValueError('Expected email address, got {!r}'.format(value))
129    return values
130
131
132@public
133def integer_ge_zero_validator(value):
134    """Validate that the value is a non-negative integer."""
135    value = int(value)
136    if value < 0:
137        raise ValueError('Expected a non-negative integer: {}'.format(value))
138    return value
139
140
141@public
142def regexp_validator(value):                           # pragma: missed
143    """Validate that the value is a valid regexp."""
144    # This code is covered as proven by the fact that the tests
145    # test_add_bad_regexp and test_patch_bad_regexp in
146    # mailman/rest/tests/test_header_matches.py fail with AssertionError:
147    # HTTPError not raised if the code is bypassed, but coverage says it's
148    # not covered so work around it for now.
149    try:
150        re.compile(value)
151    except re.error:
152        raise ValueError('Expected a valid regexp, got {}'.format(value))
153    return value
154
155
156@public
157def email_or_regexp_validator(value):
158    """ Email or regular expression validator
159
160    Validate that the value is not null and is a valid regular expression or
161     email.
162    """
163    if not value:
164        raise ValueError(
165            'Expected a valid email address or regular expression, got empty')
166    valid = True
167    # A string starts with ^ will be regarded as regex.
168    if value.startswith('^'):
169        try:
170            regexp_validator(value)
171        except ValueError:
172            valid = False
173    else:
174        valid = getUtility(IEmailValidator).is_valid(value)
175
176    if valid:
177        return value
178    else:
179        raise ValueError(
180            'Expected a valid email address or regular expression,'
181            ' got {}'.format(value))
182
183
184@public
185def email_validator(value):
186    """Validate the value is a valid email."""
187    if not getUtility(IEmailValidator).is_valid(value):
188        raise ValueError(
189            'Expected a valid email address, got {}'.format(value))
190    return value
191
192
193@public
194class Validator:
195    """A validator of parameter input."""
196
197    def __init__(self, **kws):
198        if '_optional' in kws:
199            self._optional = set(kws.pop('_optional'))
200        else:
201            self._optional = set()
202        self._converters = kws.copy()
203
204    def __call__(self, request):
205        values = {}
206        extras = set()
207        cannot_convert = set()
208        form_data = {}
209        # All keys which show up only once in the form data get a scalar value
210        # in the pre-converted dictionary.  All keys which show up more than
211        # once get a list value.
212        missing = object()
213        # Parse the items from request depending on the content type.
214        items = get_request_params(request)
215
216        for key, new_value in items.items():
217            old_value = form_data.get(key, missing)
218            if old_value is missing:
219                form_data[key] = new_value
220            elif isinstance(old_value, list):
221                old_value.append(new_value)
222            else:
223                form_data[key] = [old_value, new_value]
224        # Now do all the conversions.
225        for key, value in form_data.items():
226            try:
227                values[key] = self._converters[key](value)
228            except KeyError:
229                extras.add(key)
230            except (TypeError, ValueError) as e:
231                cannot_convert.add((key, str(e)))
232        # Make sure there are no unexpected values.
233        if len(extras) != 0:
234            extras = COMMASPACE.join(sorted(extras))
235            raise ValueError('Unexpected parameters: {}'.format(extras))
236            # raise BadRequestError(
237            #     description='Unexpected parameters: {}'.format(extras))
238        # Make sure everything could be converted.
239        if len(cannot_convert) != 0:
240            invalid_msg = []
241            for param in sorted(cannot_convert):
242                invalid_msg.append(
243                    'Invalid Parameter "{0}": {1}.'.format(*param))
244            raise ValueError(' '.join(invalid_msg))
245            # raise InvalidParamError(param_name=bad, msg=invalid_msg)
246        # Make sure nothing's missing.
247        value_keys = set(values)
248        required_keys = set(self._converters) - self._optional
249        if value_keys & required_keys != required_keys:
250            missing = COMMASPACE.join(sorted(required_keys - value_keys))
251            raise ValueError('Missing Parameter: {}'.format(missing))
252            # raise MissingParamError(param_name=missing)
253        return values
254
255    def update(self, obj, request):
256        """Update the object with the values in the request.
257
258        This first validates and converts the attributes in the request, then
259        updates the given object with the newly converted values.
260
261        :param obj: The object to update.
262        :type obj: object
263        :param request: The HTTP request.
264        :raises ValueError: if conversion failed for some attribute, including
265            if the API version mismatches.
266        """
267        for key, value in self.__call__(request).items():
268            self._converters[key].put(obj, key, value)
269
270
271@public
272class PatchValidator(Validator):
273    """Create a special validator for PATCH requests.
274
275    PATCH is different than PUT because with the latter, you're changing the
276    entire resource, so all expected attributes must exist.  With the former,
277    you're only changing a subset of the attributes, so you only validate the
278    ones that exist in the request.
279    """
280
281    def __init__(self, request, converters):
282        """Create a validator for the PATCH request.
283
284        :param request: The request object, which must have a .PATCH
285            attribute.
286        :param converters: A mapping of attribute names to the converter for
287            that attribute's type.  Generally, this will be a GetterSetter
288            instance, but it might be something more specific for custom data
289            types (e.g. non-basic types like unicodes).
290        :raises UnknownPATCHRequestError: if the request contains an unknown
291            attribute, i.e. one that is not in the `attributes` mapping.
292        :raises ReadOnlyPATCHRequest: if the requests contains an attribute
293            that is defined as read-only.
294        """
295        validationators = {}
296        # Parse the items from request depending on the content type.
297        items = get_request_params(request)
298        for attribute in items:
299            if attribute not in converters:
300                raise UnknownPATCHRequestError(attribute)
301            if converters[attribute].decoder is None:
302                raise ReadOnlyPATCHRequestError(attribute)
303            validationators[attribute] = converters[attribute]
304        super().__init__(**validationators)
305