diff options
| -rw-r--r-- | src/mailman/bin/mmsitepass.py | 113 | ||||
| -rw-r--r-- | src/mailman/config/schema.cfg | 4 | ||||
| -rw-r--r-- | src/mailman/interfaces/address.py | 6 | ||||
| -rw-r--r-- | src/mailman/model/user.py | 5 | ||||
| -rw-r--r-- | src/mailman/rest/docs/users.txt | 107 | ||||
| -rw-r--r-- | src/mailman/rest/root.py | 10 | ||||
| -rw-r--r-- | src/mailman/rest/users.py | 119 | ||||
| -rw-r--r-- | src/mailman/testing/layers.py | 13 | ||||
| -rw-r--r-- | src/mailman/utilities/datetime.py | 8 | ||||
| -rw-r--r-- | src/mailman/utilities/uid.py | 58 |
10 files changed, 315 insertions, 128 deletions
diff --git a/src/mailman/bin/mmsitepass.py b/src/mailman/bin/mmsitepass.py deleted file mode 100644 index c17d87526..000000000 --- a/src/mailman/bin/mmsitepass.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright (C) 1998-2011 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 <http://www.gnu.org/licenses/>. - -import sys -import getpass -import optparse - -from mailman import Utils -from mailman import passwords -from mailman.configuration import config -from mailman.core.i18n import _ -from mailman.initialize import initialize -from mailman.version import MAILMAN_VERSION - - - -def parseargs(): - parser = optparse.OptionParser(version=MAILMAN_VERSION, - usage=_("""\ -%prog [options] [password] - -Set the site or list creator password. - -The site password can be used in most if not all places that the list -administrator's password can be used, which in turn can be used in most places -that a list user's password can be used. The list creator password is a -separate password that can be given to non-site administrators to delegate the -ability to create new mailing lists. - -If password is not given on the command line, it will be prompted for. -""")) - parser.add_option('-c', '--listcreator', - default=False, action='store_true', - help=_("""\ -Set the list creator password instead of the site password. The list -creator is authorized to create and remove lists, but does not have -the total power of the site administrator.""")) - parser.add_option('-p', '--password-scheme', - default='', type='string', - help=_("""\ -Specify the RFC 2307 style hashing scheme for passwords included in the -output. Use -P to get a list of supported schemes, which are -case-insensitive.""")) - parser.add_option('-P', '--list-hash-schemes', - default=False, action='store_true', help=_("""\ -List the supported password hashing schemes and exit. The scheme labels are -case-insensitive.""")) - parser.add_option('-C', '--config', - help=_('Alternative configuration file to use')) - opts, args = parser.parse_args() - if len(args) > 1: - parser.error(_('Unexpected arguments')) - if opts.list_hash_schemes: - for label in passwords.Schemes: - print str(label).upper() - sys.exit(0) - return parser, opts, args - - -def check_password_scheme(parser, password_scheme): - # shoule be checked after config is loaded. - if password_scheme == '': - password_scheme = config.PASSWORD_SCHEME - scheme = passwords.lookup_scheme(password_scheme.lower()) - if not scheme: - parser.error(_('Invalid password scheme')) - return scheme - - - -def main(): - parser, opts, args = parseargs() - initialize(opts.config) - opts.password_scheme = check_password_scheme(parser, opts.password_scheme) - if args: - password = args[0] - else: - # Prompt for the password - if opts.listcreator: - prompt_1 = _('New list creator password: ') - else: - prompt_1 = _('New site administrator password: ') - pw1 = getpass.getpass(prompt_1) - pw2 = getpass.getpass(_('Enter password again to confirm: ')) - if pw1 <> pw2: - print _('Passwords do not match; no changes made.') - sys.exit(1) - password = pw1 - Utils.set_global_password(password, - not opts.listcreator, opts.password_scheme) - if Utils.check_global_password(password, not opts.listcreator): - print _('Password changed.') - else: - print _('Password change failed.') - - - -if __name__ == '__main__': - main() diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 7384294f7..030e5fc2c 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -130,6 +130,10 @@ enabled: no # enabled. This way messages can't be accidentally sent to real addresses. recipient: +# This gets set by the testing layers so that the qrunner subprocesses produce +# predictable dates and times. +testing: no + [passwords] # The default scheme to use to encrypt new passwords. Existing passwords diff --git a/src/mailman/interfaces/address.py b/src/mailman/interfaces/address.py index 391eae849..c051c9b0c 100644 --- a/src/mailman/interfaces/address.py +++ b/src/mailman/interfaces/address.py @@ -40,6 +40,12 @@ from mailman.interfaces.errors import MailmanError class AddressError(MailmanError): """A general address-related error occurred.""" + def __init__(self, address): + self.address = address + + def __str__(self): + return self.address + class ExistingAddressError(AddressError): """The given email address already exists.""" diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py index 16d5b1a2f..f0048c5f4 100644 --- a/src/mailman/model/user.py +++ b/src/mailman/model/user.py @@ -59,7 +59,10 @@ class User(Model): def __init__(self, real_name=None, preferences=None): super(User, self).__init__() self._created_on = date_factory.now() - self._user_id = uid_factory.new_uid() + user_id = uid_factory.new_uid() + assert config.db.store.find(User, _user_id=user_id).count() == 0, ( + 'Duplicate user id {0}'.format(user_id)) + self._user_id = user_id self.real_name = ('' if real_name is None else real_name) self.preferences = preferences config.db.store.add(self) diff --git a/src/mailman/rest/docs/users.txt b/src/mailman/rest/docs/users.txt new file mode 100644 index 000000000..d6ac3e4c2 --- /dev/null +++ b/src/mailman/rest/docs/users.txt @@ -0,0 +1,107 @@ +===== +Users +===== + +The REST API can be used to add and remove users, add and remove user +addresses, and change their preferred address, passord, or name. Users are +different than members; the latter represents an email address subscribed to a +specific mailing list. Users are just people that Mailman knows about. + +There are no users yet. + + >>> dump_json('http://localhost:9001/3.0/users') + http_etag: "..." + start: 0 + total_size: 0 + +When there are users in the database, they can be retrieved as a collection. +:: + + >>> from zope.component import getUtility + >>> from mailman.interfaces.usermanager import IUserManager + >>> user_manager = getUtility(IUserManager) + + >>> anne = user_manager.create_user('anne@example.com', 'Anne Person') + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/users') + entry 0: + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: None + real_name: Anne Person + user_id: 1 + http_etag: "..." + start: 0 + total_size: 1 + +The user ids match. + + >>> json = call_http('http://localhost:9001/3.0/users') + >>> json['entries'][0]['user_id'] == anne.user_id + True + + +Creating users via the API +========================== + +New users can be created through the REST API. To do so requires the initial +email address for the user, and optionally the user's full name and password. +:: + + >>> transaction.abort() + >>> dump_json('http://localhost:9001/3.0/users', { + ... 'email': 'bart@example.com', + ... 'real_name': 'Bart Person', + ... 'password': 'bbb', + ... }) + content-length: 0 + date: ... + location: http://localhost:9001/3.0/users/2 + server: ... + status: 201 + +The user exists in the database. +:: + + >>> user_manager.get_user('bart@example.com') + <User "Bart Person" (2) at ...> + +It is also available via the location given in the response. + + >>> dump_json('http://localhost:9001/3.0/users/2') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: None + real_name: Bart Person + user_id: 2 + +Because email addresses just have an ``@`` sign in then, there's no confusing +them with user ids. Thus, a user can be retrieved via its email address. + + >>> dump_json('http://localhost:9001/3.0/users/bart@example.com') + created_on: 2005-08-01T07:49:23 + http_etag: "..." + password: None + real_name: Bart Person + user_id: 2 + + +Missing users +============= + +It is of course an error to attempt to access a non-existent user, either by +user id... +:: + + >>> dump_json('http://localhost:9001/3.0/users/99') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 404: 404 Not Found + +...or by email address. +:: + + >>> dump_json('http://localhost:9001/3.0/users/zed@example.org') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 404: 404 Not Found diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index 9d8c92428..3287a6be2 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -34,6 +34,7 @@ from mailman.rest.domains import ADomain, AllDomains from mailman.rest.helpers import etag, path_to from mailman.rest.lists import AList, AllLists from mailman.rest.members import AllMembers +from mailman.rest.users import AUser, AllUsers @@ -108,3 +109,12 @@ class TopLevel(resource.Resource): if len(segments) == 0: return AllMembers() return http.bad_request() + + @resource.child() + def users(self, request, segments): + """/<api>/users""" + if len(segments) == 0: + return AllUsers() + else: + user_id = segments.pop(0) + return AUser(user_id), segments diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py new file mode 100644 index 000000000..9a00cecd2 --- /dev/null +++ b/src/mailman/rest/users.py @@ -0,0 +1,119 @@ +# Copyright (C) 2011 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 <http://www.gnu.org/licenses/>. + +"""REST for users.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AUser', + 'AllUsers', + ] + + +from restish import http, resource +from zope.component import getUtility + +from mailman.interfaces.address import ExistingAddressError +from mailman.interfaces.usermanager import IUserManager +from mailman.rest.helpers import CollectionMixin, etag, path_to +from mailman.rest.validator import Validator + + + +class _UserBase(resource.Resource, CollectionMixin): + """Shared base class for user representations.""" + + def _resource_as_dict(self, user): + """See `CollectionMixin`.""" + # The canonical URL for a user is their preferred email address, + # although we can always look up a user based on any registered and + # validated email address associated with their account. + return dict( + real_name=user.real_name, + password=user.password, + user_id=user.user_id, + created_on=user.created_on, + ) + + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(IUserManager).users) + + + +class AllUsers(_UserBase): + """The users.""" + + @resource.GET() + def collection(self, request): + """/users""" + resource = self._make_collection(request) + return http.ok([], etag(resource)) + + @resource.POST() + def create(self, request): + """Create a new user.""" + try: + validator = Validator(email=unicode, + real_name=unicode, + password=unicode, + _optional=('real_name', 'password')) + arguments = validator(request) + except ValueError as error: + return http.bad_request([], str(error)) + # We can't pass the 'password' argument to the user creation method, + # so strip that out (if it exists), then create the user, adding the + # password after the fact if successful. + password = arguments.pop('password', None) + try: + user = getUtility(IUserManager).create_user(**arguments) + except ExistingAddressError as error: + return http.bad_request([], b'Address already exists {0}'.format( + error.email)) + # XXX ignore password for now. + location = path_to('users/{0}'.format(user.user_id)) + return http.created(location, [], None) + + + +class AUser(_UserBase): + """A user.""" + + def __init__(self, user_identifier): + """Get a user by various type of identifiers. + + :param user_identifier: The identifier used to retrieve the user. The + identifier may either be an integer user-id, or an email address + controlled by the user. The type of identifier is auto-detected + by looking for an `@` symbol, in which case it's taken as an email + address, otherwise it's assumed to be an integer. + :type user_identifier: str + """ + user_manager = getUtility(IUserManager) + if '@' in user_identifier: + self._user = user_manager.get_user(user_identifier) + else: + self._user = user_manager.get_user_by_id(user_identifier) + + @resource.GET() + def user(self, request): + """Return a single user end-point.""" + if self._user is None: + return http.not_found() + return http.ok([], self._resource_as_json(self._user)) diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py index c7258dd08..29ab7169a 100644 --- a/src/mailman/testing/layers.py +++ b/src/mailman/testing/layers.py @@ -25,6 +25,7 @@ __all__ = [ 'MockAndMonkeyLayer', 'RESTLayer', 'SMTPLayer', + 'is_testing', ] @@ -108,6 +109,8 @@ class ConfigLayer(MockAndMonkeyLayer): password_scheme: cleartext [paths.testing] var_dir: %s + [devmode] + testing: yes """ % cls.var_dir) # Read the testing config and push it. test_config += resource_string('mailman.testing', 'testing.cfg') @@ -290,3 +293,13 @@ class RESTLayer(SMTPLayer): assert cls.server is not None, 'Layer not set up' cls.server.stop() cls.server = None + + + +def is_testing(): + """Return a 'testing' flag for use with the predictable factories. + + :return: True when in testing mode. + :rtype: bool + """ + return MockAndMonkeyLayer.testing_mode or config.devmode.testing diff --git a/src/mailman/utilities/datetime.py b/src/mailman/utilities/datetime.py index 9dcd21f1e..1ee727da4 100644 --- a/src/mailman/utilities/datetime.py +++ b/src/mailman/utilities/datetime.py @@ -36,7 +36,7 @@ __all__ = [ import datetime -from mailman.testing.layers import MockAndMonkeyLayer +from mailman.testing import layers @@ -51,12 +51,12 @@ class DateFactory: # We can't automatically fast-forward because some tests require us to # stay on the same day for a while, e.g. autorespond.txt. return (self.predictable_now - if MockAndMonkeyLayer.testing_mode + if layers.is_testing() else datetime.datetime.now(tz)) def today(self): return (self.predictable_today - if MockAndMonkeyLayer.testing_mode + if layers.is_testing() else datetime.date.today()) @classmethod @@ -74,4 +74,4 @@ factory = DateFactory() factory.reset() today = factory.today now = factory.now -MockAndMonkeyLayer.register_reset(factory.reset) +layers.MockAndMonkeyLayer.register_reset(factory.reset) diff --git a/src/mailman/utilities/uid.py b/src/mailman/utilities/uid.py index cc2cf3b12..6fceeb606 100644 --- a/src/mailman/utilities/uid.py +++ b/src/mailman/utilities/uid.py @@ -32,9 +32,13 @@ __all__ = [ import os import time +import errno import hashlib -from mailman.testing.layers import MockAndMonkeyLayer +from flufl.lock import Lock + +from mailman.config import config +from mailman.testing import layers from mailman.utilities.passwords import SALT_LENGTH @@ -42,14 +46,31 @@ from mailman.utilities.passwords import SALT_LENGTH class UniqueIDFactory: """A factory for unique ids.""" - # The predictable id. - predictable_id = None + def __init__(self): + # We can't call reset() when the factory is created below, because + # config.VAR_DIR will not be set at that time. So initialize it at + # the first use. + self._uid_file = None + self._lock_file = None + self._lock = None def new_uid(self, bytes=None): - if MockAndMonkeyLayer.testing_mode: - uid = self.predictable_id - self.predictable_id += 1 - return unicode(uid) + if layers.is_testing(): + if self._lock is None: + # These will get automatically cleaned up by the test + # infrastructure. + self._uid_file = os.path.join(config.VAR_DIR, '.uid') + self._lock_file = self._uid_file + '.lock' + self._lock = Lock(self._lock_file) + # When in testing mode we want to produce predictable id, but we + # need to coordinate this among separate processes. We could use + # the database, but I don't want to add schema just to handle this + # case, and besides transactions could get aborted, causing some + # ids to be recycled. So we'll use a data file with a lock. This + # may still not be ideal due to race conditions, but I think the + # tests will be serialized enough (and the ids reset between + # tests) that it will not be a problem. Maybe. + return self._next_uid() salt = os.urandom(SALT_LENGTH) h = hashlib.sha1(repr(time.time())) h.update(salt) @@ -57,11 +78,28 @@ class UniqueIDFactory: h.update(bytes) return unicode(h.hexdigest(), 'us-ascii') + def _next_uid(self): + with self._lock: + try: + with open(self._uid_file) as fp: + uid = fp.read().strip() + next_uid = int(uid) + 1 + with open(self._uid_file, 'w') as fp: + fp.write(str(next_uid)) + except IOError as error: + if error.errno != errno.ENOENT: + raise + with open(self._uid_file, 'w') as fp: + fp.write('2') + return '1' + return unicode(uid, 'us-ascii') + def reset(self): - self.predictable_id = 1 + with self._lock: + with open(self._uid_file, 'w') as fp: + fp.write('1') factory = UniqueIDFactory() -factory.reset() -MockAndMonkeyLayer.register_reset(factory.reset) +layers.MockAndMonkeyLayer.register_reset(factory.reset) |
