allura-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From hei...@apache.org
Subject [24/45] allura git commit: [#7878] Used 2to3 to see what issues would come up
Date Fri, 29 May 2015 20:40:46 GMT
http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/security.py
----------------------------------------------------------------------
diff --git a/lib/security.py b/lib/security.py
new file mode 100644
index 0000000..38419c9
--- /dev/null
+++ b/lib/security.py
@@ -0,0 +1,501 @@
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+"""
+This module provides the security predicates used in decorating various models.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+import logging
+from collections import defaultdict
+
+from pylons import tmpl_context as c
+from pylons import request
+from webob import exc
+from itertools import chain
+from ming.utils import LazyProperty
+
+from allura.lib.utils import TruthyCallable
+import collections
+
+log = logging.getLogger(__name__)
+
+
+class Credentials(object):
+
+    '''
+    Role graph logic & caching
+    '''
+
+    def __init__(self):
+        self.clear()
+
+    @property
+    def project_role(self):
+        # bypass Ming model validation and use pymongo directly
+        # for improved performance
+        from allura import model as M
+        db = M.session.main_doc_session.db
+        return db[M.ProjectRole.__mongometa__.name]
+
+    @classmethod
+    def get(cls):
+        'get the global :class:`Credentials` instance'
+        import allura
+        return allura.credentials
+
+    def clear(self):
+        'clear cache'
+        self.users = {}
+        self.projects = {}
+
+    def clear_user(self, user_id, project_id=None):
+        if project_id == '*':
+            to_remove = [(uid, pid)
+                         for uid, pid in self.users if uid == user_id]
+        else:
+            to_remove = [(user_id, project_id)]
+        for uid, pid in to_remove:
+            self.projects.pop(pid, None)
+            self.users.pop((uid, pid), None)
+
+    def load_user_roles(self, user_id, *project_ids):
+        '''Load the credentials with all user roles for a set of projects'''
+        # Don't reload roles
+        project_ids = [
+            pid for pid in project_ids if self.users.get((user_id, pid)) is None]
+        if not project_ids:
+            return
+        if user_id is None:
+            q = self.project_role.find({
+                'user_id': None,
+                'project_id': {'$in': project_ids},
+                'name': '*anonymous'})
+        else:
+            q0 = self.project_role.find({
+                'user_id': None,
+                'project_id': {'$in': project_ids},
+                'name': {'$in': ['*anonymous', '*authenticated']}})
+            q1 = self.project_role.find({
+                'user_id': user_id,
+                'project_id': {'$in': project_ids},
+                'name': None})
+            q = chain(q0, q1)
+        roles_by_project = dict((pid, []) for pid in project_ids)
+        for role in q:
+            roles_by_project[role['project_id']].append(role)
+        for pid, roles in roles_by_project.items():
+            self.users[user_id, pid] = RoleCache(self, roles)
+
+    def load_project_roles(self, *project_ids):
+        '''Load the credentials with all user roles for a set of projects'''
+        # Don't reload roles
+        project_ids = [
+            pid for pid in project_ids if self.projects.get(pid) is None]
+        if not project_ids:
+            return
+        q = self.project_role.find({
+            'project_id': {'$in': project_ids}})
+        roles_by_project = dict((pid, []) for pid in project_ids)
+        for role in q:
+            roles_by_project[role['project_id']].append(role)
+        for pid, roles in roles_by_project.items():
+            self.projects[pid] = RoleCache(self, roles)
+
+    def project_roles(self, project_id):
+        '''
+        :returns: a :class:`RoleCache` of :class:`ProjectRoles <allura.model.auth.ProjectRole>` for project_id
+        '''
+        roles = self.projects.get(project_id)
+        if roles is None:
+            self.load_project_roles(project_id)
+            roles = self.projects[project_id]
+        return roles
+
+    def user_roles(self, user_id, project_id=None):
+        '''
+        :returns: a :class:`RoleCache` of :class:`ProjectRoles <allura.model.auth.ProjectRole>` for given user_id and optional project_id, ``*anonymous`` and ``*authenticated`` checked as appropriate
+        '''
+        roles = self.users.get((user_id, project_id))
+        if roles is None:
+            if project_id is None:
+                if user_id is None:
+                    q = []
+                else:
+                    q = self.project_role.find({'user_id': user_id})
+                roles = RoleCache(self, q)
+            else:
+                self.load_user_roles(user_id, project_id)
+                roles = self.users.get((user_id, project_id))
+            self.users[user_id, project_id] = roles
+        return roles
+
+    def user_has_any_role(self, user_id, project_id, role_ids):
+        user_roles = self.user_roles(user_id=user_id, project_id=project_id)
+        return bool(set(role_ids) & user_roles.reaching_ids_set)
+
+    def users_with_named_role(self, project_id, name):
+        """ returns in sorted order """
+        role = RoleCache(self, [r for r in self.project_roles(project_id) if r.get('name') == name])
+        return sorted(role.users_that_reach, key=lambda u: u.username)
+
+    def userids_with_named_role(self, project_id, name):
+        role = RoleCache(self, self.project_role.find({'project_id': project_id, 'name': name}))
+        return role.userids_that_reach
+
+
+class RoleCache(object):
+    '''
+    An iterable collection of :class:`ProjectRoles <allura.model.auth.ProjectRole>` that is cached after first use
+    '''
+
+    def __init__(self, cred, q):
+        '''
+        :param `Credentials` cred: :class:`Credentials`
+        :param iterable q: An iterable (e.g a query) of :class:`ProjectRoles <allura.model.auth.ProjectRole>`
+        '''
+        self.cred = cred
+        self.q = q
+
+    def find(self, **kw):
+        tests = list(kw.items())
+
+        def _iter():
+            for r in self:
+                for k, v in tests:
+                    val = r.get(k)
+                    if isinstance(v, collections.Callable):
+                        if not v(val):
+                            break
+                    elif v != val:
+                        break
+                else:
+                    yield r
+        return RoleCache(self.cred, _iter())
+
+    def get(self, **kw):
+        for x in self.find(**kw):
+            return x
+        return None
+
+    def __iter__(self):
+        return iter(self.index.values())
+
+    def __len__(self):
+        return len(self.index)
+
+    @LazyProperty
+    def index(self):
+        return dict((r['_id'], r) for r in self.q)
+
+    @LazyProperty
+    def named(self):
+        return RoleCache(self.cred, (
+            r for r in self
+            if r.get('name') and not r.get('name').startswith('*')))
+
+    @LazyProperty
+    def reverse_index(self):
+        rev_index = defaultdict(list)
+        for r in self:
+            for rr_id in r['roles']:
+                rev_index[rr_id].append(r)
+        return rev_index
+
+    @LazyProperty
+    def roles_that_reach(self):
+        def _iter():
+            visited = set()
+            to_visit = list(self)
+            while to_visit:
+                r = to_visit.pop(0)
+                if r['_id'] in visited:
+                    continue
+                visited.add(r['_id'])
+                yield r
+                pr_rindex = self.cred.project_roles(
+                    r['project_id']).reverse_index
+                to_visit += pr_rindex[r['_id']]
+        return RoleCache(self.cred, _iter())
+
+    @LazyProperty
+    def users_that_reach(self):
+        from allura import model as M
+        uids = [uid for uid in self.userids_that_reach if uid]
+        return M.User.query.find({'_id': {'$in': uids}})
+
+    @LazyProperty
+    def userids_that_reach(self):
+        return [r['user_id'] for r in self.roles_that_reach]
+
+    @LazyProperty
+    def reaching_roles(self):
+        def _iter():
+            to_visit = list(self.index.items())
+            project_ids = set([r['project_id'] for _id, r in to_visit])
+            pr_index = {r['_id']: r for r in self.cred.project_role.find({
+                'project_id': {'$in': list(project_ids)},
+                'user_id': None,
+            })}
+            visited = set()
+            while to_visit:
+                (rid, role) = to_visit.pop()
+                if rid in visited:
+                    continue
+                yield role
+                for i in role['roles']:
+                    if i in pr_index:
+                        to_visit.append((i, pr_index[i]))
+        return RoleCache(self.cred, _iter())
+
+    @LazyProperty
+    def reaching_ids(self):
+        return [r['_id'] for r in self.reaching_roles]
+
+    @LazyProperty
+    def reaching_ids_set(self):
+        return set(self.reaching_ids)
+
+
+def has_access(obj, permission, user=None, project=None):
+    '''Return whether the given user has the permission name on the given object.
+
+    - First, all the roles for a user in the given project context are computed.
+
+    - If the given object's ACL contains a DENY for this permission on this
+      user's project role, return False and deny access.  TODO: make ACL order
+      matter instead of doing DENY first; see ticket [#6715]
+
+    - Next, for each role, the given object's ACL is examined linearly. If an ACE
+      is found which matches the permission and user, and that ACE ALLOWs access,
+      then the function returns True and access is permitted. If the ACE DENYs
+      access, then that role is removed from further consideration.
+
+    - If the obj is not a Neighborhood and the given user has the 'admin'
+      permission on the current neighborhood, then the function returns True and
+      access is allowed.
+
+    - If the obj is not a Project and the given user has the 'admin'
+      permission on the current project, then the function returns True and
+      access is allowed.
+
+    - If none of the ACEs on the object ALLOW access, and there are no more roles
+      to be considered, then the function returns False and access is denied.
+
+    - Processing continues using the remaining roles and the
+      obj.parent_security_context(). If the parent_security_context is None, then
+      the function returns False and access is denied.
+
+    The effect of this processing is that:
+
+      1. If the user's project_role is DENYed, access is denied (e.g. if the user
+         has been blocked for a permission on a specific tool).
+
+      2. Else, if *any* role for the user is ALLOWed access via a linear
+         traversal of the ACLs, then access is allowed.
+
+      3. Otherwise, DENY access to the resource.
+    '''
+    from allura import model as M
+
+    def predicate(obj=obj, user=user, project=project, roles=None):
+        if obj is None:
+            return False
+        if roles is None:
+            if user is None:
+                user = c.user
+            assert user, 'c.user should always be at least M.User.anonymous()'
+            cred = Credentials.get()
+            if project is None:
+                if isinstance(obj, M.Neighborhood):
+                    project = obj.neighborhood_project
+                    if project is None:
+                        log.error('Neighborhood project missing for %s', obj)
+                        return False
+                elif isinstance(obj, M.Project):
+                    project = obj.root_project
+                else:
+                    project = getattr(obj, 'project', None) or c.project
+                    project = project.root_project
+            roles = cred.user_roles(
+                user_id=user._id, project_id=project._id).reaching_ids
+
+        # TODO: move deny logic into loop below; see ticket [#6715]
+        if user != M.User.anonymous():
+            user_roles = Credentials.get().user_roles(user_id=user._id,
+                                                      project_id=project.root_project._id)
+            for r in user_roles:
+                deny_user = M.ACE.deny(r['_id'], permission)
+                if M.ACL.contains(deny_user, obj.acl):
+                    return False
+
+        chainable_roles = []
+        for rid in roles:
+            for ace in obj.acl:
+                if M.ACE.match(ace, rid, permission):
+                    if ace.access == M.ACE.ALLOW:
+                        # access is allowed
+                        # log.info('%s: True', txt)
+                        return True
+                    else:
+                        # access is denied for this role
+                        break
+            else:
+                # access neither allowed or denied, may chain to parent context
+                chainable_roles.append(rid)
+        parent = obj.parent_security_context()
+        if parent and chainable_roles:
+            result = has_access(parent, permission, user=user, project=project)(
+                roles=tuple(chainable_roles))
+        elif not isinstance(obj, M.Neighborhood):
+            result = has_access(project.neighborhood, 'admin', user=user)()
+            if not (result or isinstance(obj, M.Project)):
+                result = has_access(project, 'admin', user=user)()
+        else:
+            result = False
+        # log.info('%s: %s', txt, result)
+        return result
+    return TruthyCallable(predicate)
+
+
+def all_allowed(obj, user_or_role=None, project=None):
+    '''
+    List all the permission names that a given user or named role
+    is allowed for a given object.  This list reflects the permissions
+    for which ``has_access()`` would return True for the user (or a user
+    in the given named role, e.g. Developer).
+
+    Example:
+
+        Given a tracker with the following ACL (pseudo-code)::
+
+            [
+                ACE.allow(ProjectRole.by_name('Developer'), 'create'),
+                ACE.allow(ProjectRole.by_name('Member'), 'post'),
+                ACE.allow(ProjectRole.by_name('*anonymous'), 'read'),
+            ]
+
+        And user1 is in the Member group, then ``all_allowed(tracker, user1)``
+        will return::
+
+            set(['post', 'read'])
+
+        And ``all_allowed(tracker, ProjectRole.by_name('Developer'))`` will return::
+
+            set(['create', 'post', 'read'])
+    '''
+    from allura import model as M
+    anon = M.ProjectRole.anonymous(project)
+    auth = M.ProjectRole.authenticated(project)
+    if user_or_role is None:
+        user_or_role = c.user
+    if user_or_role is None:
+        user_or_role = anon
+    if isinstance(user_or_role, M.User):
+        user_or_role = M.ProjectRole.by_user(user_or_role, project)
+        if user_or_role is None:
+            user_or_role = auth  # user is not member of project, treat as auth
+    roles = [user_or_role]
+    if user_or_role == anon:
+        pass  # anon inherits nothing
+    elif user_or_role == auth:
+        roles += [anon]  # auth inherits from anon
+    else:
+        roles += [auth, anon]  # named group or user inherits from auth + anon
+    # match rules applicable to us
+    role_ids = RoleCache(Credentials.get(), roles).reaching_ids
+    perms = set()
+    denied = defaultdict(set)
+    while obj:  # traverse parent contexts
+        for role_id in role_ids:
+            for ace in obj.acl:
+                if ace.permission in denied[role_id]:
+                    # don't consider permissions that were denied for this role
+                    continue
+                if M.ACE.match(ace, role_id, ace.permission):
+                    if ace.access == M.ACE.ALLOW:
+                        perms.add(ace.permission)
+                    else:
+                        # explicit DENY overrides any ALLOW for this permission
+                        # for this role_id in this ACL or parent(s) (but an ALLOW
+                        # for a different role could still grant this
+                        # permission)
+                        denied[role_id].add(ace.permission)
+        obj = obj.parent_security_context()
+    if M.ALL_PERMISSIONS in perms:
+        return set([M.ALL_PERMISSIONS])
+    return perms
+
+
+def require(predicate, message=None):
+    '''
+    Example: ``require(has_access(c.app, 'read'))``
+
+    :param callable predicate: truth function to call
+    :param str message: message to show upon failure
+    :raises: HTTPForbidden or HTTPUnauthorized
+    '''
+
+    from allura import model as M
+    if predicate():
+        return
+    if not message:
+        message = """You don't have permission to do that.
+                     You must ask a project administrator for rights to perform this task.
+                     Please click the back button to return to the previous page."""
+    if c.user != M.User.anonymous():
+        request.environ['error_message'] = message
+        raise exc.HTTPForbidden(detail=message)
+    else:
+        raise exc.HTTPUnauthorized()
+
+
+def require_access(obj, permission, **kwargs):
+    if obj is not None:
+        predicate = has_access(obj, permission, **kwargs)
+        return require(predicate, message='%s access required' % permission.capitalize())
+    else:
+        raise exc.HTTPForbidden(
+            detail="Could not verify permissions for this page.")
+
+
+def require_authenticated():
+    '''
+    :raises: HTTPUnauthorized if current user is anonymous
+    '''
+    from allura import model as M
+    if c.user == M.User.anonymous():
+        raise exc.HTTPUnauthorized()
+
+
+def simple_grant(acl, role_id, permission):
+    from allura.model.types import ACE
+    for ace in acl:
+        if ace.role_id == role_id and ace.permission == permission:
+            return
+    acl.append(ACE.allow(role_id, permission))
+
+
+def simple_revoke(acl, role_id, permission):
+    remove = []
+    for i, ace in enumerate(acl):
+        if ace.role_id == role_id and ace.permission == permission:
+            remove.append(i)
+    for i in reversed(remove):
+        acl.pop(i)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/solr.py
----------------------------------------------------------------------
diff --git a/lib/solr.py b/lib/solr.py
new file mode 100644
index 0000000..e2a3ff1
--- /dev/null
+++ b/lib/solr.py
@@ -0,0 +1,190 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import shlex
+
+from tg import config
+from paste.deploy.converters import asbool
+import pysolr
+
+escape_rules = {'+': r'\+',
+               '-': r'\-',
+               '&': r'\&',
+               '|': r'\|',
+               '!': r'\!',
+               '(': r'\(',
+               ')': r'\)',
+               '{': r'\{',
+               '}': r'\}',
+               '[': r'\[',
+               ']': r'\]',
+               '^': r'\^',
+               '~': r'\~',
+               '*': r'\*',
+               '?': r'\?',
+               ':': r'\:',
+               '"': r'\"',
+               ';': r'\;'}
+
+
+def escape_solr_arg(term):
+    """ Apply escaping to the passed in query terms
+        escaping special characters like : , etc"""
+    term = term.replace('\\', r'\\')   # escape \ first
+    for char, escaped_char in escape_rules.items():
+        term = term.replace(char, escaped_char)
+
+    return term
+
+
+def make_solr_from_config(push_servers, query_server=None, **kwargs):
+    """
+    Make a :class:`Solr <Solr>` instance from config defaults.  Use
+    `**kwargs` to override any value
+    """
+    solr_kwargs = dict(
+        commit=asbool(config.get('solr.commit', True)),
+        commitWithin=config.get('solr.commitWithin'),
+        timeout=int(config.get('solr.long_timeout', 60)),
+    )
+    solr_kwargs.update(kwargs)
+    return Solr(push_servers, query_server, **solr_kwargs)
+
+
+class Solr(object):
+
+    """Solr interface that pushes updates to multiple solr instances.
+
+    `push_servers`: list of servers to push to.
+    `query_server`: server to read from. Uses `push_servers[0]` if not specified.
+
+    Also, accepts default values for `commit` and `commitWithin`
+    and passes those values through to each `add` and `delete` call,
+    unless explicitly overridden.
+    """
+
+    def __init__(self, push_servers, query_server=None,
+                 commit=True, commitWithin=None, **kw):
+        self.push_pool = [pysolr.Solr(s, **kw) for s in push_servers]
+        if query_server:
+            self.query_server = pysolr.Solr(query_server, **kw)
+        else:
+            self.query_server = self.push_pool[0]
+        self._commit = commit
+        self.commitWithin = commitWithin
+
+    def add(self, *args, **kw):
+        if 'commit' not in kw:
+            kw['commit'] = self._commit
+        if self.commitWithin and 'commitWithin' not in kw:
+            kw['commitWithin'] = self.commitWithin
+        responses = []
+        for solr in self.push_pool:
+            responses.append(solr.add(*args, **kw))
+        return responses
+
+    def delete(self, *args, **kw):
+        if 'commit' not in kw:
+            kw['commit'] = self._commit
+        responses = []
+        for solr in self.push_pool:
+            responses.append(solr.delete(*args, **kw))
+        return responses
+
+    def commit(self, *args, **kw):
+        responses = []
+        for solr in self.push_pool:
+            responses.append(solr.commit(*args, **kw))
+        return responses
+
+    def search(self, *args, **kw):
+        return self.query_server.search(*args, **kw)
+
+
+class MockSOLR(object):
+
+    class MockHits(list):
+
+        @property
+        def hits(self):
+            return len(self)
+
+        @property
+        def docs(self):
+            return self
+
+        @property
+        def facets(self):
+            return {'facet_fields': {}}
+
+    def __init__(self):
+        self.db = {}
+
+    def add(self, objects):
+        for o in objects:
+            o['text'] = ''.join(o['text'])
+            self.db[o['id']] = o
+
+    def commit(self):
+        pass
+
+    def search(self, q, fq=None, **kw):
+        if q is None: q = ''  # shlex will hang on None
+        if isinstance(q, str):
+            q = q.encode('latin-1')
+        # Parse query
+        preds = []
+        q_parts = shlex.split(q)
+        if fq:
+            q_parts += fq
+        for part in q_parts:
+            if part == '&&':
+                continue
+            if ':' in part:
+                field, value = part.split(':', 1)
+                preds.append((field, value))
+            else:
+                preds.append(('text', part))
+        result = self.MockHits()
+        for obj in list(self.db.values()):
+            for field, value in preds:
+                neg = False
+                if field[0] == '!':
+                    neg = True
+                    field = field[1:]
+                if field == 'text' or field.endswith('_t'):
+                    if (value not in str(obj.get(field, ''))) ^ neg:
+                        break
+                else:
+                    if (value != str(obj.get(field, ''))) ^ neg:
+                        break
+            else:
+                result.append(obj)
+        return result
+
+    def delete(self, *args, **kwargs):
+        if kwargs.get('q', None) == '*:*':
+            self.db = {}
+        elif kwargs.get('id', None):
+            del self.db[kwargs['id']]
+        elif kwargs.get('q', None):
+            for doc in self.search(kwargs['q']):
+                self.delete(id=doc['id'])

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/spam/__init__.py
----------------------------------------------------------------------
diff --git a/lib/spam/__init__.py b/lib/spam/__init__.py
new file mode 100644
index 0000000..77a25bb
--- /dev/null
+++ b/lib/spam/__init__.py
@@ -0,0 +1,57 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import logging
+
+from allura.lib.helpers import exceptionless
+
+log = logging.getLogger(__name__)
+
+
+class SpamFilter(object):
+
+    """Defines the spam checker interface and provides a default no-op impl."""
+
+    def __init__(self, config):
+        pass
+
+    def check(self, text, artifact=None, user=None, content_type='comment', **kw):
+        """Return True if ``text`` is spam, else False."""
+        log.info("No spam checking enabled")
+        return False
+
+    def submit_spam(self, text, artifact=None, user=None, content_type='comment', **kw):
+        log.info("No spam checking enabled")
+
+    def submit_ham(self, text, artifact=None, user=None, content_type='comment', **kw):
+        log.info("No spam checking enabled")
+
+    @classmethod
+    def get(cls, config, entry_points):
+        """Return an instance of the SpamFilter impl specified in ``config``.
+        """
+        method = config.get('spam.method')
+        if not method:
+            return cls(config)
+        result = entry_points[method]
+        filter_obj = result(config)
+        filter_obj.check = exceptionless(False, log=log)(filter_obj.check)
+        return filter_obj

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/spam/akismetfilter.py
----------------------------------------------------------------------
diff --git a/lib/spam/akismetfilter.py b/lib/spam/akismetfilter.py
new file mode 100644
index 0000000..454e3bc
--- /dev/null
+++ b/lib/spam/akismetfilter.py
@@ -0,0 +1,107 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import logging
+
+from pylons import request
+from pylons import tmpl_context as c
+
+from allura.lib import helpers as h
+from allura.lib import utils
+from allura.lib.spam import SpamFilter
+
+try:
+    import akismet
+    AKISMET_AVAILABLE = True
+except ImportError:
+    AKISMET_AVAILABLE = False
+
+
+log = logging.getLogger(__name__)
+
+
+class AkismetSpamFilter(SpamFilter):
+
+    """Spam checking implementation via Akismet service.
+
+    To enable Akismet spam filtering in your Allura instance, first
+    enable the entry point in setup.py::
+
+        [allura.spam]
+        akismet = allura.lib.spam.akismetfilter:AkismetSpamFilter
+
+    Then include the following parameters in your .ini file::
+
+        spam.method = akismet
+        spam.key = <your Akismet key here>
+    """
+
+    def __init__(self, config):
+        if not AKISMET_AVAILABLE:
+            raise ImportError('akismet not available')
+        self.service = akismet.Akismet(
+            config.get('spam.key'), config.get('base_url'))
+        self.service.verify_key()
+
+    def get_data(self, text, artifact=None, user=None, content_type='comment', **kw):
+        kw['comment_content'] = text
+        kw['comment_type'] = content_type
+        if artifact:
+            kw['permalink'] = artifact.url()
+        user = user or c.user
+        if user:
+            kw['comment_author'] = user.display_name or user.username
+            kw['comment_author_email'] = user.email_addresses[
+                0] if user.email_addresses else ''
+        kw['user_ip'] = utils.ip_address(request)
+        kw['user_agent'] = request.headers.get('USER_AGENT')
+        kw['referrer'] = request.headers.get('REFERER')
+        # kw will be urlencoded, need to utf8-encode
+        for k, v in list(kw.items()):
+            kw[k] = h.really_unicode(v).encode('utf8')
+        return kw
+
+    def check(self, text, artifact=None, user=None, content_type='comment', **kw):
+        log_msg = text
+        res = self.service.comment_check(text,
+                                         data=self.get_data(text=text,
+                                                            artifact=artifact,
+                                                            user=user,
+                                                            content_type=content_type),
+                                         build_data=False)
+        log.info("spam=%s (akismet): %s" % (str(res), log_msg))
+        return res
+
+    def submit_spam(self, text, artifact=None, user=None, content_type='comment'):
+        self.service.submit_spam(text,
+                                 data=self.get_data(text=text,
+                                                    artifact=artifact,
+                                                    user=user,
+                                                    content_type=content_type),
+                                 build_data=False)
+
+    def submit_ham(self, text, artifact=None, user=None, content_type='comment'):
+        self.service.submit_ham(text,
+                                data=self.get_data(text=text,
+                                                   artifact=artifact,
+                                                   user=user,
+                                                   content_type=content_type),
+                                build_data=False)

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/spam/mollomfilter.py
----------------------------------------------------------------------
diff --git a/lib/spam/mollomfilter.py b/lib/spam/mollomfilter.py
new file mode 100644
index 0000000..2bd21c9
--- /dev/null
+++ b/lib/spam/mollomfilter.py
@@ -0,0 +1,97 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import logging
+
+from pylons import request
+from pylons import tmpl_context as c
+
+from allura.lib import helpers as h
+from allura.lib import utils
+from allura.lib.spam import SpamFilter
+
+try:
+    import Mollom
+    MOLLOM_AVAILABLE = True
+except ImportError:
+    MOLLOM_AVAILABLE = False
+
+
+log = logging.getLogger(__name__)
+
+
+class MollomSpamFilter(SpamFilter):
+
+    """Spam checking implementation via Mollom service.
+
+    To enable Mollom spam filtering in your Allura instance, first
+    enable the entry point in setup.py::
+
+        [allura.spam]
+        mollom = allura.lib.spam.mollomfilter:MollomSpamFilter
+
+    Then include the following parameters in your .ini file::
+
+        spam.method = mollom
+        spam.public_key = <your Mollom public key here>
+        spam.private_key = <your Mollom private key here>
+    """
+
+    def __init__(self, config):
+        if not MOLLOM_AVAILABLE:
+            raise ImportError('Mollom not available')
+        self.service = Mollom.MollomAPI(
+            publicKey=config.get('spam.public_key'),
+            privateKey=config.get('spam.private_key'))
+        if not self.service.verifyKey():
+            raise Mollom.MollomFault('Your MOLLOM credentials are invalid.')
+
+    def check(self, text, artifact=None, user=None, content_type='comment', **kw):
+        """Basic content spam check via Mollom. For more options
+        see http://mollom.com/api#api-content
+        """
+        log_msg = text
+        kw['postBody'] = text
+        if artifact:
+            # Should be able to send url, but can't right now due to a bug in
+            # the PyMollom lib
+            # kw['url'] = artifact.url()
+            log_msg = artifact.url()
+        user = user or c.user
+        if user:
+            kw['authorName'] = user.display_name or user.username
+            kw['authorMail'] = user.email_addresses[
+                0] if user.email_addresses else ''
+        kw['authorIP'] = utils.ip_address(request)
+        # kw will be urlencoded, need to utf8-encode
+        for k, v in list(kw.items()):
+            kw[k] = h.really_unicode(v).encode('utf8')
+        cc = self.service.checkContent(**kw)
+        res = cc['spam'] == 2
+        artifact.spam_check_id = cc.get('session_id', '')
+        log.info("spam=%s (mollom): %s" % (str(res), log_msg))
+        return res
+
+    def submit_spam(self, text, artifact=None, user=None, content_type='comment', **kw):
+        self.service.sendFeedback(artifact.spam_check_id, 'spam')
+
+    def submit_ham(self, *args, **kw):
+        log.info("Mollom doesn't support reporting a ham")

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/stats.py
----------------------------------------------------------------------
diff --git a/lib/stats.py b/lib/stats.py
new file mode 100644
index 0000000..cc6766b
--- /dev/null
+++ b/lib/stats.py
@@ -0,0 +1,89 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+
+from time import time
+from contextlib import contextmanager
+from pylons import request
+
+
+class StatsRecord(object):
+
+    def __init__(self, request, active):
+        self.timers = dict(
+            mongo=0,
+            template=0,
+            total=0)
+        self.url = request.environ['PATH_INFO']
+        self.active = active
+        # Avoid double-timing things
+        self._now_timing = set()
+
+    def __repr__(self):
+        stats = ' '.join(
+            ('%s=%.0fms' % (k, v * 1000))
+            for k, v in sorted(self.timers.items()))
+        return '%s: %s' % (self.url, stats)
+
+    def asdict(self):
+        return dict(
+            url=self.url,
+            timers=self.timers)
+
+    @contextmanager
+    def timing(self, name):
+        if self.active and name not in self._now_timing:
+            self._now_timing.add(name)
+            self.timers.setdefault(name, 0)
+            begin = time()
+            try:
+                yield
+            finally:
+                end = time()
+                self.timers[name] += end - begin
+                self._now_timing.remove(name)
+        else:
+            yield
+
+
+class timing(object):
+
+    '''Decorator to time a method call'''
+
+    def __init__(self, timer):
+        self.timer = timer
+
+    def __call__(self, func):
+        def inner(*l, **kw):
+            try:
+                stats = request.environ['sf.stats']
+            except TypeError:
+                return func(*l, **kw)
+            with stats.timing(self.timer):
+                return func(*l, **kw)
+        inner.__name__ = func.__name__
+        return inner
+
+    def decorate(self, obj, names):
+        names = names.split()
+        for name in names:
+            setattr(obj, name,
+                    self(getattr(obj, name)))

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/utils.py
----------------------------------------------------------------------
diff --git a/lib/utils.py b/lib/utils.py
new file mode 100644
index 0000000..7e15649
--- /dev/null
+++ b/lib/utils.py
@@ -0,0 +1,612 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import time
+import string
+import hashlib
+import binascii
+import logging.handlers
+import codecs
+import os.path
+import datetime
+import random
+import mimetypes
+import re
+import magic
+from itertools import groupby
+import collections
+
+import tg
+import pylons
+import json
+import webob.multidict
+from formencode import Invalid
+from tg.decorators import before_validate
+from pylons import response
+from pylons import tmpl_context as c
+from pylons.controllers.util import etag_cache
+from paste.deploy.converters import asbool, asint
+from paste.httpheaders import CACHE_CONTROL, EXPIRES
+from webhelpers.html import literal
+from webob import exc
+from pygments.formatters import HtmlFormatter
+from setproctitle import getproctitle
+import html5lib.sanitizer
+
+from ew import jinja2_ew as ew
+from ming.utils import LazyProperty
+from ming.odm.odmsession import ODMCursor
+
+
+MARKDOWN_EXTENSIONS = ['.markdown', '.mdown', '.mkdn', '.mkd', '.md']
+
+
+def permanent_redirect(url):
+    try:
+        tg.redirect(url)
+    except exc.HTTPFound as err:
+        raise exc.HTTPMovedPermanently(location=err.location)
+
+
+def guess_mime_type(filename):
+    '''Guess MIME type based on filename.
+    Applies heuristics, tweaks, and defaults in centralized manner.
+    '''
+    # Consider changing to strict=False
+    content_type = mimetypes.guess_type(filename, strict=True)
+    if content_type[0]:
+        content_type = content_type[0]
+    else:
+        content_type = 'application/octet-stream'
+    return content_type
+
+
+class ConfigProxy(object):
+
+    '''Wrapper for loading config values at module-scope so we don't
+    have problems when a module is imported before tg.config is initialized
+    '''
+
+    def __init__(self, **kw):
+        self._kw = kw
+
+    def __getattr__(self, k):
+        return self.get(k)
+
+    def get(self, key, default=None):
+        return tg.config.get(self._kw.get(key, key), default)
+
+    def get_bool(self, key):
+        return asbool(self.get(key))
+
+
+class lazy_logger(object):
+
+    '''Lazy instatiation of a logger, to ensure that it does not get
+    created before logging is configured (which would make it disabled)'''
+
+    def __init__(self, name):
+        self._name = name
+
+    @LazyProperty
+    def _logger(self):
+        return logging.getLogger(self._name)
+
+    def __getattr__(self, name):
+        if name.startswith('_'):
+            raise AttributeError(name)
+        return getattr(self._logger, name)
+
+
+class TimedRotatingHandler(logging.handlers.BaseRotatingHandler):
+
+    def __init__(self, strftime_pattern):
+        self.pattern = strftime_pattern
+        self.last_filename = self.current_filename()
+        logging.handlers.BaseRotatingHandler.__init__(
+            self, self.last_filename, 'a')
+
+    def current_filename(self):
+        return os.path.abspath(datetime.datetime.utcnow().strftime(self.pattern))
+
+    def shouldRollover(self, record):
+        'Inherited from BaseRotatingFileHandler'
+        return self.current_filename() != self.last_filename
+
+    def doRollover(self):
+        self.stream.close()
+        self.baseFilename = self.current_filename()
+        if self.encoding:
+            self.stream = codecs.open(self.baseFilename, 'w', self.encoding)
+        else:
+            self.stream = open(self.baseFilename, 'w')
+
+
+class StatsHandler(TimedRotatingHandler):
+    fields = (
+        'action', 'action_type', 'tool_type', 'tool_mount', 'project', 'neighborhood',
+        'username', 'url', 'ip_address')
+
+    def __init__(self,
+                 strftime_pattern,
+                 module='allura',
+                 page=1,
+                 **kwargs):
+        self.page = page
+        self.module = module
+        TimedRotatingHandler.__init__(self, strftime_pattern)
+
+    def emit(self, record):
+        if not hasattr(record, 'action'):
+            return
+        kwpairs = dict(
+            module=self.module,
+            page=self.page)
+        for name in self.fields:
+            kwpairs[name] = getattr(record, name, None)
+        kwpairs.update(getattr(record, 'kwpairs', {}))
+        record.kwpairs = ','.join(
+            '%s=%s' % (k, v) for k, v in sorted(kwpairs.items())
+            if v is not None)
+        record.exc_info = None  # Never put tracebacks in the rtstats log
+        TimedRotatingHandler.emit(self, record)
+
+
+class CustomWatchedFileHandler(logging.handlers.WatchedFileHandler):
+
+    """Custom log handler for Allura"""
+
+    def format(self, record):
+        """Prepends current process name to ``record.name`` if running in the
+        context of a taskd process that is currently processing a task.
+
+        """
+        title = getproctitle()
+        if title.startswith('taskd:'):
+            record.name = "{0}:{1}".format(title, record.name)
+        return super(CustomWatchedFileHandler, self).format(record)
+
+
+def chunked_find(cls, query=None, pagesize=1024, sort_key='_id', sort_dir=1):
+    '''
+    Execute a mongo query against the specified class, yield some results at
+    a time (avoids mongo cursor timeouts if the total result set is very large).
+
+    Pass an indexed sort_key for efficient queries.  Default _id should work
+    in most cases.
+    '''
+    if query is None:
+        query = {}
+    page = 0
+    max_id = None
+    while True:
+        if sort_key:
+            if max_id:
+                if sort_key not in query:
+                    query[sort_key] = {}
+                query[sort_key]['$gt'] = max_id
+            q = cls.query.find(query).limit(pagesize).sort(sort_key, sort_dir)
+        else:
+            # skipping requires scanning, even for an indexed query
+            q = cls.query.find(query).limit(pagesize).skip(pagesize * page)
+        results = (q.all())
+        if not results:
+            break
+        if sort_key:
+            max_id = results[-1][sort_key]
+        yield results
+        page += 1
+
+
+def lsub_utf8(s, n):
+    '''Useful for returning n bytes of a UTF-8 string, rather than characters'''
+    while len(s) > n:
+        k = n
+        while (ord(s[k]) & 0xc0) == 0x80:
+            k -= 1
+        return s[:k]
+    return s
+
+
+def chunked_list(l, n):
+    """ Yield successive n-sized chunks from l.
+    """
+    for i in range(0, len(l), n):
+        yield l[i:i + n]
+
+
+def chunked_iter(iterable, max_size):
+    '''return iterable 'chunks' from the iterable of max size max_size'''
+    eiter = enumerate(iterable)
+    keyfunc = lambda i_x: i_x[0] // max_size
+    for _, chunk in groupby(eiter, keyfunc):
+        yield (x for i, x in chunk)
+
+
+class AntiSpam(object):
+
+    '''Helper class for bot-protecting forms'''
+    honey_field_template = string.Template('''<p class="$honey_class">
+    <label for="$fld_id">You seem to have CSS turned off.
+        Please don't fill out this field.</label><br>
+    <input id="$fld_id" name="$fld_name" type="text"><br></p>''')
+
+    def __init__(self, request=None, num_honey=2):
+        self.num_honey = num_honey
+        if request is None or request.method == 'GET':
+            self.request = pylons.request
+            self.timestamp = int(time.time())
+            self.spinner = self.make_spinner()
+            self.timestamp_text = str(self.timestamp)
+            self.spinner_text = self._wrap(self.spinner)
+        else:
+            self.request = request
+            self.timestamp_text = request.params['timestamp']
+            self.spinner_text = request.params['spinner']
+            self.timestamp = int(self.timestamp_text)
+            self.spinner = self._unwrap(self.spinner_text)
+        self.spinner_ord = list(map(ord, self.spinner))
+        self.random_padding = [random.randint(0, 255) for x in self.spinner]
+        self.honey_class = self.enc(self.spinner_text, css_safe=True)
+
+        # The counter is to ensure that multiple forms in the same page
+        # don't end up with the same id.  Instead of doing:
+        #
+        # honey0, honey1
+        # which just relies on 0..num_honey we include a counter
+        # which is incremented every time extra_fields is called:
+        #
+        # honey00, honey 01, honey10, honey11
+        self.counter = 0
+
+    @staticmethod
+    def _wrap(s):
+        '''Encode a string to make it HTML id-safe (starts with alpha, includes
+        only digits, hyphens, underscores, colons, and periods).  Luckily, base64
+        encoding doesn't use hyphens, underscores, colons, nor periods, so we'll
+        use these characters to replace its plus, slash, equals, and newline.
+        '''
+        tx_tbl = string.maketrans('+/', '-_')
+        s = binascii.b2a_base64(s)
+        s = s.rstrip('=\n')
+        s = s.translate(tx_tbl)
+        s = 'X' + s
+        return s
+
+    @staticmethod
+    def _unwrap(s):
+        tx_tbl = string.maketrans('-_', '+/')
+        s = s[1:]
+        s = str(s).translate(tx_tbl)
+        i = len(s) % 4
+        if i > 0:
+            s += '=' * (4 - i)
+        s = binascii.a2b_base64(s + '\n')
+        return s
+
+    def enc(self, plain, css_safe=False):
+        '''Stupid fieldname encryption.  Not production-grade, but
+        hopefully "good enough" to stop spammers.  Basically just an
+        XOR of the spinner with the unobfuscated field name
+        '''
+        # Plain starts with its length, includes the ordinals for its
+        #   characters, and is padded with random data
+        plain = ([len(plain)]
+                 + list(map(ord, plain))
+                 + self.random_padding[:len(self.spinner_ord) - len(plain) - 1])
+        enc = ''.join(chr(p ^ s) for p, s in zip(plain, self.spinner_ord))
+        enc = self._wrap(enc)
+        if css_safe:
+            enc = ''.join(ch for ch in enc if ch.isalpha())
+        return enc
+
+    def dec(self, enc):
+        enc = self._unwrap(enc)
+        enc = list(map(ord, enc))
+        plain = [e ^ s for e, s in zip(enc, self.spinner_ord)]
+        plain = plain[1:1 + plain[0]]
+        plain = ''.join(map(chr, plain))
+        return plain
+
+    def extra_fields(self):
+        yield ew.HiddenField(name='timestamp', value=self.timestamp_text).display()
+        yield ew.HiddenField(name='spinner', value=self.spinner_text).display()
+        for fldno in range(self.num_honey):
+            fld_name = self.enc('honey%d' % (fldno))
+            fld_id = self.enc('honey%d%d' % (self.counter, fldno))
+            yield literal(self.honey_field_template.substitute(
+                honey_class=self.honey_class,
+                fld_id=fld_id,
+                fld_name=fld_name))
+        self.counter += 1
+
+    def make_spinner(self, timestamp=None):
+        if timestamp is None:
+            timestamp = self.timestamp
+        try:
+            client_ip = ip_address(self.request)
+        except (TypeError, AttributeError) as err:
+            client_ip = '127.0.0.1'
+        plain = '%d:%s:%s' % (
+            timestamp, client_ip, pylons.config.get('spinner_secret', 'abcdef'))
+        return hashlib.sha1(plain).digest()
+
+    @classmethod
+    def validate_request(cls, request=None, now=None, params=None):
+        if request is None:
+            request = pylons.request
+        if params is None:
+            params = request.params
+        new_params = dict(params)
+        if not request.method == 'GET':
+            new_params.pop('timestamp', None)
+            new_params.pop('spinner', None)
+            obj = cls(request)
+            if now is None:
+                now = time.time()
+            if obj.timestamp > now + 5:
+                raise ValueError('Post from the future')
+            if now - obj.timestamp > 24 * 60 * 60:
+                raise ValueError('Post from the distant past')
+            if obj.spinner != obj.make_spinner(obj.timestamp):
+                raise ValueError('Bad spinner value')
+            for k in list(new_params.keys()):
+                new_params[obj.dec(k)] = new_params.pop(k)
+            for fldno in range(obj.num_honey):
+                value = new_params.pop('honey%s' % fldno)
+                if value:
+                    raise ValueError('Value in honeypot field: %s' % value)
+        return new_params
+
+    @classmethod
+    def validate(cls, error_msg):
+        '''Controller decorator to raise Invalid errors if bot protection is engaged'''
+        def antispam_hook(remainder, params):
+            '''Converts various errors in validate_request to a single Invalid message'''
+            try:
+                new_params = cls.validate_request(params=params)
+                params.update(new_params)
+            except (ValueError, TypeError, binascii.Error):
+                raise Invalid(error_msg, params, None)
+        return before_validate(antispam_hook)
+
+
+class TruthyCallable(object):
+
+    '''
+    Wraps a callable to make it truthy in a boolean context.
+
+    Assumes the callable returns a truthy value and can be called with no args.
+    '''
+
+    def __init__(self, callable):
+        self.callable = callable
+
+    def __call__(self, *args, **kw):
+        return self.callable(*args, **kw)
+
+    def __bool__(self):
+        return self.callable()
+
+
+class TransformedDict(collections.MutableMapping):
+
+    """
+    A dictionary which applies an arbitrary
+    key-altering function before accessing the keys.
+
+    From: http://stackoverflow.com/questions/3387691/python-how-to-perfectly-override-a-dict
+    """
+
+    def __init__(self, *args, **kwargs):
+        self.store = dict()
+        self.update(dict(*args, **kwargs))  # use the free update to set keys
+
+    def __getitem__(self, key):
+        return self.store[self.__keytransform__(key)]
+
+    def __setitem__(self, key, value):
+        self.store[self.__keytransform__(key)] = value
+
+    def __delitem__(self, key):
+        del self.store[self.__keytransform__(key)]
+
+    def __iter__(self):
+        return iter(self.store)
+
+    def __len__(self):
+        return len(self.store)
+
+    def __keytransform__(self, key):
+        return key
+
+
+class CaseInsensitiveDict(TransformedDict):
+
+    def __keytransform__(self, key):
+        return key.lower()
+
+
+def postmortem_hook(etype, value, tb):  # pragma no cover
+    import sys
+    import pdb
+    import traceback
+    try:
+        from IPython.ipapi import make_session
+        make_session()
+        from IPython.Debugger import Pdb
+        sys.stderr.write('Entering post-mortem IPDB shell\n')
+        p = Pdb(color_scheme='Linux')
+        p.reset()
+        p.setup(None, tb)
+        p.print_stack_trace()
+        sys.stderr.write('%s: %s\n' % (etype, value))
+        p.cmdloop()
+        p.forget()
+        # p.interaction(None, tb)
+    except ImportError:
+        sys.stderr.write('Entering post-mortem PDB shell\n')
+        traceback.print_exception(etype, value, tb)
+        pdb.post_mortem(tb)
+
+
+class LineAnchorCodeHtmlFormatter(HtmlFormatter):
+
+    def _wrap_pre(self, inner):
+        style = []
+        if self.prestyles:
+            style.append(self.prestyles)
+        if self.noclasses:
+            style.append('line-height: 125%')
+        style = '; '.join(style)
+
+        num = self.linenostart
+        yield 0, ('<pre' + (style and ' style="%s"' % style) + '>')
+        for tup in inner:
+            yield (tup[0], '<div id="l%s" class="code_block">%s</div>' % (num, tup[1]))
+            num += 1
+        yield 0, '</pre>'
+
+
+def generate_code_stats(blob):
+    stats = {'line_count': 0,
+             'code_size': 0,
+             'data_line_count': 0}
+    code = blob.text
+    lines = code.split('\n')
+    stats['code_size'] = blob.size
+    stats['line_count'] = len(lines)
+    spaces = re.compile(r'^\s*$')
+    stats['data_line_count'] = sum([1 for l in lines if not spaces.match(l)])
+    return stats
+
+
+def is_text_file(file):
+    msg = magic.from_buffer(file[:1024])
+    if ("text" in msg) or ("empty" in msg):
+        return True
+    return False
+
+
+def take_while_true(source):
+    x = source()
+    while x:
+        yield x
+        x = source()
+
+
+def serve_file(fp, filename, content_type, last_modified=None,
+        cache_expires=None, size=None, embed=True, etag=None):
+    '''Sets the response headers and serves as a wsgi iter'''
+    if not etag and filename and last_modified:
+        etag = '{0}?{1}'.format(filename, last_modified).encode('utf-8')
+    if etag:
+        etag_cache(etag)
+    pylons.response.headers['Content-Type'] = ''
+    pylons.response.content_type = content_type.encode('utf-8')
+    pylons.response.cache_expires = cache_expires or asint(
+        tg.config.get('files_expires_header_secs', 60 * 60))
+    pylons.response.last_modified = last_modified
+    if size:
+        pylons.response.content_length = size
+    if 'Pragma' in pylons.response.headers:
+        del pylons.response.headers['Pragma']
+    if 'Cache-Control' in pylons.response.headers:
+        del pylons.response.headers['Cache-Control']
+    if not embed:
+        pylons.response.headers.add(
+            'Content-Disposition',
+            'attachment;filename="%s"' % filename.encode('utf-8'))
+    # http://code.google.com/p/modwsgi/wiki/FileWrapperExtension
+    block_size = 4096
+    if 'wsgi.file_wrapper' in tg.request.environ:
+        return tg.request.environ['wsgi.file_wrapper'](fp, block_size)
+    else:
+        return iter(lambda: fp.read(block_size), '')
+
+
+class ForgeHTMLSanitizer(html5lib.sanitizer.HTMLSanitizer):
+
+    valid_iframe_srcs = ('https://www.youtube.com/embed/', 'https://www.gittip.com/')
+
+    def sanitize_token(self, token):
+        if 'iframe' in self.allowed_elements:
+            self.allowed_elements.remove('iframe')
+        if token.get('name') == 'iframe':
+            attrs = dict(token.get('data'))
+            if attrs.get('src', '').startswith(self.valid_iframe_srcs):
+                self.allowed_elements.append('iframe')
+        return super(ForgeHTMLSanitizer, self).sanitize_token(token)
+
+
+def ip_address(request):
+    ip = request.remote_addr
+    if tg.config.get('ip_address_header'):
+        ip = request.headers.get(tg.config['ip_address_header']) or ip
+    return ip
+
+
+class EmptyCursor(ODMCursor):
+    """Ming cursor with no results"""
+
+    def __init__(self, *args, **kw):
+        pass
+
+    @property
+    def extensions(self):
+        return []
+
+    def count(self):
+        return 0
+
+    def _next_impl(self):
+        raise StopIteration
+
+    def __next__(self):
+        raise StopIteration
+
+    def options(self, **kw):
+        return self
+
+    def limit(self, limit):
+        return self
+
+    def skip(self, skip):
+        return self
+
+    def hint(self, index_or_name):
+        return self
+
+    def sort(self, *args, **kw):
+        return self
+
+
+class DateJSONEncoder(json.JSONEncoder):
+    def default(self, obj):
+        if isinstance(obj, datetime.datetime):
+            return obj.strftime('%Y-%m-%dT%H:%M:%SZ')
+        return json.JSONEncoder.default(self, obj)
+
+
+def phone_number_hash(number):
+    pattern = re.compile('\W+')
+    number = pattern.sub('', number)
+    return hashlib.sha1(number).hexdigest()

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/validators.py
----------------------------------------------------------------------
diff --git a/lib/validators.py b/lib/validators.py
new file mode 100644
index 0000000..d0ce0a7
--- /dev/null
+++ b/lib/validators.py
@@ -0,0 +1,424 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import json
+import re
+from bson import ObjectId
+import formencode as fe
+from formencode import validators as fev
+from pylons import tmpl_context as c
+from . import helpers as h
+from datetime import datetime
+
+
+class URL(fev.URL):
+    # allows use of IP address instead of domain name
+    require_tld = False
+
+    url_re = re.compile(r'''
+        ^(http|https)://
+        (?:[%:\w]*@)?                              # authenticator
+        (?:                                        # ip or domain
+        (?P<ip>(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))|
+        (?P<domain>[a-z0-9][a-z0-9\-]{,62}\.)*     # subdomain
+        (?P<tld>[a-z]{2,63}|xn--[a-z0-9\-]{2,59})  # top level domain
+        )
+        (?::[0-9]{1,5})?                           # port
+        # files/delims/etc
+        (?P<path>/[a-z0-9\-\._~:/\?#\[\]@!%\$&\'\(\)\*\+,;=]*)?
+        $
+    ''', re.I | re.VERBOSE)
+
+
+class NonHttpUrl(URL):
+    messages = {
+        'noScheme': 'You must start your URL with a scheme',
+    }
+    add_http = False
+    scheme_re = re.compile(r'^[a-z][a-z0-9.+-]*:', re.I)
+    url_re = re.compile(r'''
+        ^([a-z][a-z0-9.+-]*)://
+        (?:[%:\w]*@)?                              # authenticator
+        (?:                                        # ip or domain
+        (?P<ip>(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))|
+        (?P<domain>[a-z0-9][a-z0-9\-]{,62}\.)*     # subdomain
+        (?P<tld>[a-z]{2,63}|xn--[a-z0-9\-]{2,59})  # top level domain
+        )
+        (?::[0-9]{1,5})?                           # port
+        # files/delims/etc
+        (?P<path>/[a-z0-9\-\._~:/\?#\[\]@!%\$&\'\(\)\*\+,;=]*)?
+        $
+    ''', re.I | re.VERBOSE)
+
+
+class Ming(fev.FancyValidator):
+
+    def __init__(self, cls, **kw):
+        self.cls = cls
+        super(Ming, self).__init__(**kw)
+
+    def _to_python(self, value, state):
+        result = self.cls.query.get(_id=value)
+        if result is None:
+            try:
+                result = self.cls.query.get(_id=ObjectId(value))
+            except:
+                pass
+        return result
+
+    def _from_python(self, value, state):
+        return value._id
+
+
+class UniqueOAuthApplicationName(fev.UnicodeString):
+
+    def _to_python(self, value, state):
+        from allura import model as M
+        app = M.OAuthConsumerToken.query.get(name=value, user_id=c.user._id)
+        if app is not None:
+            raise fe.Invalid(
+                'That name is already taken, please choose another', value, state)
+        return value
+
+
+class NullValidator(fev.Validator):
+
+    def to_python(self, value, state):
+        return value
+
+    def from_python(self, value, state):
+        return value
+
+    def validate(self, value, state):
+        return value
+
+
+class MaxBytesValidator(fev.FancyValidator):
+    max = 255
+
+    def _to_python(self, value, state):
+        value = h.really_unicode(value or '').encode('utf-8')
+        if len(value) > self.max:
+            raise fe.Invalid("Please enter a value less than %s bytes long." %
+                             self.max, value, state)
+        return value
+
+    def from_python(self, value, state):
+        return h.really_unicode(value or '')
+
+
+class MountPointValidator(fev.UnicodeString):
+
+    def __init__(self, app_class,
+                 reserved_mount_points=('feed', 'index', 'icon', '_nav.json'), **kw):
+        super(self.__class__, self).__init__(**kw)
+        self.app_class = app_class
+        self.reserved_mount_points = reserved_mount_points
+
+    def _to_python(self, value, state):
+        mount_point, App = value, self.app_class
+        if not App.relaxed_mount_points:
+            mount_point = mount_point.lower()
+        if not App.validate_mount_point(mount_point):
+            raise fe.Invalid('Mount point "%s" is invalid' % mount_point,
+                             value, state)
+        if mount_point in self.reserved_mount_points:
+            raise fe.Invalid('Mount point "%s" is reserved' % mount_point,
+                             value, state)
+        if c.project and c.project.app_instance(mount_point) is not None:
+            raise fe.Invalid(
+                'Mount point "%s" is already in use' % mount_point,
+                value, state)
+        return mount_point
+
+    def empty_value(self, value):
+        base_mount_point = mount_point = self.app_class.default_mount_point
+        i = 0
+        while True:
+            if not c.project or c.project.app_instance(mount_point) is None:
+                return mount_point
+            mount_point = base_mount_point + '-%d' % i
+            i += 1
+
+
+class TaskValidator(fev.FancyValidator):
+
+    def _to_python(self, value, state):
+        try:
+            mod, func = value.rsplit('.', 1)
+        except ValueError:
+            raise fe.Invalid('Invalid task name. Please provide the full '
+                             'dotted path to the python callable.', value, state)
+        try:
+            mod = __import__(mod, fromlist=[str(func)])
+        except ImportError:
+            raise fe.Invalid('Could not import "%s"' % value, value, state)
+
+        try:
+            task = getattr(mod, func)
+        except AttributeError:
+            raise fe.Invalid('Module has no attribute "%s"' %
+                             func, value, state)
+
+        if not hasattr(task, 'post'):
+            raise fe.Invalid('"%s" is not a task.' % value, value, state)
+        return task
+
+
+class UserValidator(fev.FancyValidator):
+
+    def _to_python(self, value, state):
+        from allura import model as M
+        user = M.User.by_username(value)
+        if not user:
+            raise fe.Invalid('Invalid username', value, state)
+        return user
+
+
+class AnonymousValidator(fev.FancyValidator):
+
+    def _to_python(self, value, state):
+        from allura.model import User
+        if value:
+            if c.user == User.anonymous():
+                raise fe.Invalid('Log in to Mark as Private', value, state)
+            else:
+                return value
+
+
+class PathValidator(fev.FancyValidator):
+
+    def _to_python(self, value, state):
+        from allura import model as M
+
+        parts = value.strip('/').split('/')
+        if len(parts) < 2:
+            raise fe.Invalid("You must specify at least a neighborhood and "
+                             "project, i.e. '/nbhd/project'", value, state)
+        elif len(parts) == 2:
+            nbhd_name, project_name, app_name = parts[0], parts[1], None
+        elif len(parts) > 2:
+            nbhd_name, project_name, app_name = parts[0], parts[1], parts[2]
+
+        path_parts = {}
+        nbhd_url_prefix = '/%s/' % nbhd_name
+        nbhd = M.Neighborhood.query.get(url_prefix=nbhd_url_prefix)
+        if not nbhd:
+            raise fe.Invalid('Invalid neighborhood: %s' %
+                             nbhd_url_prefix, value, state)
+
+        project = M.Project.query.get(
+            shortname=nbhd.shortname_prefix + project_name,
+            neighborhood_id=nbhd._id)
+        if not project:
+            raise fe.Invalid('Invalid project: %s' %
+                             project_name, value, state)
+
+        path_parts['project'] = project
+        if app_name:
+            app = project.app_instance(app_name)
+            if not app:
+                raise fe.Invalid('Invalid app mount point: %s' %
+                                 app_name, value, state)
+            path_parts['app'] = app
+
+        return path_parts
+
+
+class JsonValidator(fev.FancyValidator):
+
+    """Validates a string as JSON and returns the original string"""
+
+    def _to_python(self, value, state):
+        try:
+            json.loads(value)
+        except ValueError as e:
+            raise fe.Invalid('Invalid JSON: ' + str(e), value, state)
+        return value
+
+
+class JsonConverter(fev.FancyValidator):
+
+    """Deserializes a string to JSON and returns a Python object"""
+
+    def _to_python(self, value, state):
+        try:
+            obj = json.loads(value)
+        except ValueError as e:
+            raise fe.Invalid('Invalid JSON: ' + str(e), value, state)
+        return obj
+
+
+class JsonFile(fev.FieldStorageUploadConverter):
+
+    """Validates that a file is JSON and returns the deserialized Python object
+
+    """
+
+    def _to_python(self, value, state):
+        return JsonConverter.to_python(value.value)
+
+
+class UserMapJsonFile(JsonFile):
+
+    """Validates that a JSON file conforms to this format:
+
+    {str:str, ...}
+
+    and returns a deserialized or stringified copy of it.
+
+    """
+
+    def __init__(self, as_string=False):
+        self.as_string = as_string
+
+    def _to_python(self, value, state):
+        value = super(self.__class__, self)._to_python(value, state)
+        try:
+            for k, v in value.items():
+                if not(isinstance(k, str) and isinstance(v, str)):
+                    raise
+            return json.dumps(value) if self.as_string else value
+        except:
+            raise fe.Invalid(
+                'User map file must contain mapping of {str:str, ...}',
+                value, state)
+
+
+class CreateTaskSchema(fe.Schema):
+    task = TaskValidator(not_empty=True, strip=True)
+    task_args = JsonConverter(if_missing=dict(args=[], kwargs={}))
+    user = UserValidator(strip=True, if_missing=None)
+    path = PathValidator(strip=True, if_missing={}, if_empty={})
+
+
+class DateValidator(fev.FancyValidator):
+
+    def _to_python(self, value, state):
+        value = convertDate(value)
+        if not value:
+            raise fe.Invalid(
+                "Please enter a valid date in the format DD/MM/YYYY.",
+                value, state)
+        return value
+
+
+class TimeValidator(fev.FancyValidator):
+
+    def _to_python(self, value, state):
+        value = convertTime(value)
+        if not value:
+            raise fe.Invalid(
+                "Please enter a valid time in the format HH:MM.",
+                value, state)
+        return value
+
+
+class OneOfValidator(fev.FancyValidator):
+
+    def __init__(self, validvalues, not_empty=True):
+        self.validvalues = validvalues
+        self.not_empty = not_empty
+        super(OneOfValidator, self).__init__()
+
+    def _to_python(self, value, state):
+        if not value.strip():
+            if self.not_empty:
+                raise fe.Invalid("This field can't be empty.", value, state)
+            else:
+                return None
+        if not value in self.validvalues:
+            allowed = ''
+            for v in self.validvalues:
+                if allowed != '':
+                    allowed = allowed + ', '
+                allowed = allowed + '"%s"' % v
+            raise fe.Invalid(
+                "Invalid value. The allowed values are %s." % allowed,
+                value, state)
+        return value
+
+
+class MapValidator(fev.FancyValidator):
+
+    def __init__(self, mapvalues, not_empty=True):
+        self.map = mapvalues
+        self.not_empty = not_empty
+        super(MapValidator, self).__init__()
+
+    def _to_python(self, value, state):
+        if not value.strip():
+            if self.not_empty:
+                raise fe.Invalid("This field can't be empty.", value, state)
+            else:
+                return None
+        conv_value = self.map.get(value)
+        if not conv_value:
+            raise fe.Invalid(
+                "Invalid value. Please, choose one of the valid values.",
+                value, state)
+        return conv_value
+
+
+class YouTubeConverter(fev.FancyValidator):
+    """Takes a given YouTube URL. Ensures that the video_id
+    is contained in the URL. Returns a clean URL to use for iframe embedding.
+
+    REGEX: http://stackoverflow.com/a/10315969/25690
+    """
+
+    REGEX = ('^(?:https?:\/\/)?(?:www\.)?'+
+             '(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))'+
+             '((\w|-){11})(?:\S+)?$')
+
+    def _to_python(self, value, state):
+        match = re.match(YouTubeConverter.REGEX, value)
+        if match:
+            video_id = match.group(1)
+            return 'www.youtube.com/embed/{}?rel=0'.format(video_id)
+        else:
+            raise fe.Invalid(
+                "The URL does not appear to be a valid YouTube video.",
+                value, state)
+
+def convertDate(datestring):
+    formats = ['%Y-%m-%d', '%Y.%m.%d', '%Y/%m/%d', '%Y\%m\%d', '%Y %m %d',
+               '%d-%m-%Y', '%d.%m.%Y', '%d/%m/%Y', '%d\%m\%Y', '%d %m %Y']
+
+    for f in formats:
+        try:
+            date = datetime.strptime(datestring, f)
+            return date
+        except:
+            pass
+    return None
+
+
+def convertTime(timestring):
+    formats = ['%H:%M', '%H.%M', '%H %M', '%H,%M']
+
+    for f in formats:
+        try:
+            time = datetime.strptime(timestring, f)
+            return {'h': time.hour, 'm': time.minute}
+        except:
+            pass
+    return None

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/widgets/__init__.py
----------------------------------------------------------------------
diff --git a/lib/widgets/__init__.py b/lib/widgets/__init__.py
new file mode 100644
index 0000000..32b669c
--- /dev/null
+++ b/lib/widgets/__init__.py
@@ -0,0 +1,26 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+from .discuss import Post, Thread, Discussion
+from .subscriptions import SubscriptionForm
+from .oauth_widgets import OAuthApplicationForm, OAuthRevocationForm
+from .auth_widgets import LoginForm, ForgottenPasswordForm, DisableAccountForm
+from .vote import VoteForm

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/widgets/analytics.py
----------------------------------------------------------------------
diff --git a/lib/widgets/analytics.py b/lib/widgets/analytics.py
new file mode 100644
index 0000000..c41f205
--- /dev/null
+++ b/lib/widgets/analytics.py
@@ -0,0 +1,29 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import ew
+
+
+class GoogleAnalytics(ew.Widget):
+    template = 'jinja:allura:templates/widgets/analytics.html'
+    defaults = dict(
+        ew.Widget.defaults,
+        accounts=['UA-XXXXX-X'])

http://git-wip-us.apache.org/repos/asf/allura/blob/d52f8e2a/lib/widgets/auth_widgets.py
----------------------------------------------------------------------
diff --git a/lib/widgets/auth_widgets.py b/lib/widgets/auth_widgets.py
new file mode 100644
index 0000000..eedfee6
--- /dev/null
+++ b/lib/widgets/auth_widgets.py
@@ -0,0 +1,100 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+from __future__ import unicode_literals
+#       Licensed to the Apache Software Foundation (ASF) under one
+#       or more contributor license agreements.  See the NOTICE file
+#       distributed with this work for additional information
+#       regarding copyright ownership.  The ASF licenses this file
+#       to you under the Apache License, Version 2.0 (the
+#       "License"); you may not use this file except in compliance
+#       with the License.  You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#       Unless required by applicable law or agreed to in writing,
+#       software distributed under the License is distributed on an
+#       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#       KIND, either express or implied.  See the License for the
+#       specific language governing permissions and limitations
+#       under the License.
+
+import ew as ew_core
+import ew.jinja2_ew as ew
+from ew.core import validator
+
+from pylons import request, tmpl_context as c
+from formencode import Invalid
+from webob import exc
+
+from .forms import ForgeForm
+
+from allura.lib import plugin
+from allura import model as M
+
+
+class LoginForm(ForgeForm):
+    submit_text = 'Login'
+    style = 'wide'
+
+    @property
+    def fields(self):
+        fields = [
+            ew.TextField(name='username', label='Username', attrs={
+                'autofocus': 'autofocus',
+            }),
+            ew.PasswordField(name='password', label='Password'),
+            ew.Checkbox(
+                name='rememberme',
+                label='Remember Me',
+                attrs={'style': 'margin-left: 162px;'}),
+            ew.HiddenField(name='return_to'),
+        ]
+        if plugin.AuthenticationProvider.get(request).forgotten_password_process:
+            # only show link if auth provider has method of recovering password
+            fields.append(
+                ew.HTMLField(
+                    name='link',
+                    text='<a href="/auth/forgotten_password" style="margin-left:162px" target="_top">'
+                         'Forgot password?</a>'))
+        return fields
+
+    @validator
+    def validate(self, value, state=None):
+        try:
+            value['username'] = plugin.AuthenticationProvider.get(request).login()
+        except exc.HTTPUnauthorized:
+            msg = 'Invalid login'
+            raise Invalid(
+                msg,
+                dict(username=value['username'], rememberme=value.get('rememberme'),
+                     return_to=value.get('return_to')),
+                None)
+        except exc.HTTPBadRequest as e:
+            raise Invalid(
+                e.message,
+                dict(username=value['username'], rememberme=value.get('rememberme'),
+                     return_to=value.get('return_to')),
+                None)
+        return value
+
+
+class ForgottenPasswordForm(ForgeForm):
+    submit_text = 'Recover password'
+    style = 'wide'
+
+    class fields(ew_core.NameList):
+        email = ew.TextField(label='Your e-mail')
+
+class DisableAccountForm(ForgeForm):
+    submit_text = 'Disable'
+
+    class fields(ew_core.NameList):
+        password = ew.PasswordField(name='password', label='Account password')
+
+    @validator
+    def validate(self, value, state=None):
+        provider = plugin.AuthenticationProvider.get(request)
+        if not provider.validate_password(c.user, value['password']):
+            raise Invalid('Invalid password', {}, None)
+        return value


Mime
View raw message