You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2016/04/29 23:01:28 UTC

allura git commit: [#8082] options for rate-limiting ticket and wiki page creation, per-user across the whole site

Repository: allura
Updated Branches:
  refs/heads/db/8082 [created] f9ca424f3


[#8082] options for rate-limiting ticket and wiki page creation, per-user across the whole site


Project: http://git-wip-us.apache.org/repos/asf/allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/allura/commit/f9ca424f
Tree: http://git-wip-us.apache.org/repos/asf/allura/tree/f9ca424f
Diff: http://git-wip-us.apache.org/repos/asf/allura/diff/f9ca424f

Branch: refs/heads/db/8082
Commit: f9ca424f30a226aed3d5e0d9ab63777ae8633156
Parents: ec3cf88
Author: Dave Brondsema <da...@brondsema.net>
Authored: Fri Apr 29 17:01:20 2016 -0400
Committer: Dave Brondsema <da...@brondsema.net>
Committed: Fri Apr 29 17:01:20 2016 -0400

----------------------------------------------------------------------
 Allura/allura/model/artifact.py                 | 32 ++++++++++++++++----
 Allura/development.ini                          | 24 +++++++++------
 ForgeTracker/forgetracker/model/ticket.py       |  7 +++++
 ForgeTracker/forgetracker/tracker_main.py       |  4 +--
 .../forgewiki/tests/functional/test_root.py     | 29 ++++++++++++++++--
 ForgeWiki/forgewiki/wiki_main.py                | 10 +++---
 requirements.txt                                |  2 +-
 7 files changed, 84 insertions(+), 24 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/allura/blob/f9ca424f/Allura/allura/model/artifact.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/artifact.py b/Allura/allura/model/artifact.py
index 29931b7..e6b4b89 100644
--- a/Allura/allura/model/artifact.py
+++ b/Allura/allura/model/artifact.py
@@ -431,21 +431,23 @@ class Artifact(MappedClass, SearchIndexable):
         return h.gen_message_id(self._id)
 
     @classmethod
-    def is_limit_exceeded(cls, app_config):
+    def is_limit_exceeded(cls, app_config, user=None, count_by_user=None):
         """
         Returns True if any of artifact creation rate limits are exceeded,
         False otherwise
         """
         pkg = cls.__module__.split('.', 1)[0]
         opt = u'{}.rate_limits'.format(pkg)
-        count = cls.query.find(dict(app_config_id=app_config._id)).count()
+        count_in_app = cls.query.find(dict(app_config_id=app_config._id)).count()
         provider = plugin.ProjectRegistrationProvider.get()
         start = provider.registration_date(app_config.project)
-        # have to have the replace because, the generation_time is offset-aware
-        # UTC and h.rate_limit uses offset-naive UTC dates
+        # need the replace because the generation_time is offset-aware UTC and h.rate_limit uses offset-naive UTC dates
         start = start.replace(tzinfo=None)
+
         try:
-            h.rate_limit(opt, count, start)
+            h.rate_limit(opt, count_in_app, start)
+            if user and count_by_user is not None:
+                h.rate_limit(opt + '_per_user', count_by_user, user.registration_date())
         except forge_exc.RatelimitError:
             return True
         return False
@@ -458,7 +460,9 @@ class Snapshot(Artifact):
         session = artifact_orm_session
         name = 'artifact_snapshot'
         unique_indexes = [('artifact_class', 'artifact_id', 'version')]
-        indexes = [('artifact_id', 'version')]
+        indexes = [('artifact_id', 'version'),
+                   'author.id',
+                   ]
 
     _id = FieldProperty(S.ObjectId)
     artifact_id = FieldProperty(S.ObjectId)
@@ -617,6 +621,22 @@ class VersionedArtifact(Artifact):
         HC = self.__mongometa__.history_class
         HC.query.remove(dict(artifact_id=self._id))
 
+    @classmethod
+    def is_limit_exceeded(cls, *args, **kwargs):
+        if 'user' in kwargs:
+            # count distinct items, not total (e.g. many edits to a single wiki page doesn't count against you)
+            HC = cls.__mongometa__.history_class
+            distinct_artifacts_by_user = HC.query.find({'author.id': kwargs['user']._id}).distinct('artifact_id')
+            """
+            # some useful debugging:
+            log.info(distinct_artifacts_by_user)
+            for art_id in distinct_artifacts_by_user:
+                art = cls.query.get(_id=art_id)
+                log.info('   ' + art.url())
+            """
+            kwargs['count_by_user'] = len(distinct_artifacts_by_user)
+        return super(VersionedArtifact, cls).is_limit_exceeded(*args, **kwargs)
+
 
 class Message(Artifact):
 

http://git-wip-us.apache.org/repos/asf/allura/blob/f9ca424f/Allura/development.ini
----------------------------------------------------------------------
diff --git a/Allura/development.ini b/Allura/development.ini
index bad2383..3760896 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -450,21 +450,27 @@ forgemail.port = 8825
 ; your mail and DNS configuration
 forgemail.domain = .in.localhost
 
-; Specify the number of projects allowed to be created by a user
-; depending on the age of their user account.
-; Keys are number of seconds, values are max number of projects allowed
-; (including the default user-project, so you probably want to add 1)
-; This example allows for 1 project if the account is less than an hour old
-; and 5 projects if the account is less than a day old.  No limits after that
+; Specify the number of projects allowed to be created by a user depending on the age of their user account.
+; Keys are number of seconds, values are max number allowed until that time period is reached.
+; NOTE: this includes the default user-profile project, so you probably want to set any limits higher by 1.
+; This example allows for up to 2 total projects if the account is less than an hour old
+; and 6 total projects if the account is less than a day old.  No limits after that.
 ;project.rate_limits = {"3600": 2, "86400": 6}
 
-; Specify the number of artifacts allowed to be created by a user
-; depending on the age of the project.
+; Specify the number of artifacts allowed to be created in a project, depending on the age of the project
 ; Currently supports only tickets and wiki page creation rate limiting.
-; See project.rate_limits help text above for keys & values meaning.
+; Keys are number of seconds, values are max number allowed until that time period is reached
 ;forgewiki.rate_limits = {"3600": 100, "172800": 10000}
 ;forgetracker.rate_limits = {"3600": 100, "172800": 10000}
 
+; Number of different pages a user can create or edit, per time period, across all projects
+; NOTE: this includes default "Home" wiki page created for the user-project and any other projects created by the user
+; Keys are number of seconds, values are max number allowed until that time period is reached
+;forgewiki.rate_limits_per_user = {"60": 1, "120": 3, "900": 5, "1800": 7, "3600": 10, "7200": 15, "86400": 20, "604800": 50, "2592000": 200}
+; Number of tickets a user can create, per time period, across all projects
+; Keys are number of seconds, values are max number allowed until that time period is reached
+;forgetracker.rate_limits_per_user = {"60": 1, "120": 3, "900": 5, "1800": 7, "3600": 10, "7200": 15, "86400": 20, "604800": 50, "2592000": 200}
+
 ; set this to "false" if you are deploying to production and want performance improvements
 auto_reload_templates = true
 

http://git-wip-us.apache.org/repos/asf/allura/blob/f9ca424f/ForgeTracker/forgetracker/model/ticket.py
----------------------------------------------------------------------
diff --git a/ForgeTracker/forgetracker/model/ticket.py b/ForgeTracker/forgetracker/model/ticket.py
index c21a7d8..3cc89a2 100644
--- a/ForgeTracker/forgetracker/model/ticket.py
+++ b/ForgeTracker/forgetracker/model/ticket.py
@@ -615,6 +615,7 @@ class Ticket(VersionedArtifact, ActivityObject, VotableArtifact):
             'ticket_num',
             ('app_config_id', 'custom_fields._milestone'),
             'import_id',
+            'reported_by_id',
         ]
         unique_indexes = [
             ('app_config_id', 'ticket_num'),
@@ -1295,6 +1296,12 @@ class Ticket(VersionedArtifact, ActivityObject, VotableArtifact):
             self.app.config.options.get('AllowEmailPosting', True),
             discussion_disabled=self.discussion_disabled)
 
+    @classmethod
+    def is_limit_exceeded(cls, *args, **kwargs):
+        if 'user' in kwargs:
+            kwargs['count_by_user'] = cls.query.find({'reported_by_id': kwargs['user']._id}).count()
+        return super(Ticket, cls).is_limit_exceeded(*args, **kwargs)
+
 
 class TicketAttachment(BaseAttachment):
     thumbnail_size = (100, 100)

http://git-wip-us.apache.org/repos/asf/allura/blob/f9ca424f/ForgeTracker/forgetracker/tracker_main.py
----------------------------------------------------------------------
diff --git a/ForgeTracker/forgetracker/tracker_main.py b/ForgeTracker/forgetracker/tracker_main.py
index 7e9036f..50ea154 100644
--- a/ForgeTracker/forgetracker/tracker_main.py
+++ b/ForgeTracker/forgetracker/tracker_main.py
@@ -657,7 +657,7 @@ class RootController(BaseController, FeedController):
         require_access(c.app, 'read')
 
     def rate_limit(self, redir='..'):
-        if TM.Ticket.is_limit_exceeded(c.app.config):
+        if TM.Ticket.is_limit_exceeded(c.app.config, user=c.user):
             msg = 'Ticket creation rate limit exceeded. '
             log.warn(msg + c.app.config.url())
             flash(msg + 'Please try again later.', 'error')
@@ -1830,7 +1830,7 @@ class RootRestController(BaseController, AppRestControllerMixin):
     @validate(W.ticket_form, error_handler=h.json_validation_error)
     def new(self, ticket_form=None, **post_data):
         require_access(c.app, 'create')
-        if TM.Ticket.is_limit_exceeded(c.app.config):
+        if TM.Ticket.is_limit_exceeded(c.app.config, user=c.user):
             msg = 'Ticket creation rate limit exceeded. '
             log.warn(msg + c.app.config.url())
             raise forge_exc.HTTPTooManyRequests()

http://git-wip-us.apache.org/repos/asf/allura/blob/f9ca424f/ForgeWiki/forgewiki/tests/functional/test_root.py
----------------------------------------------------------------------
diff --git a/ForgeWiki/forgewiki/tests/functional/test_root.py b/ForgeWiki/forgewiki/tests/functional/test_root.py
index 26c0949..fbe529f 100644
--- a/ForgeWiki/forgewiki/tests/functional/test_root.py
+++ b/ForgeWiki/forgewiki/tests/functional/test_root.py
@@ -804,7 +804,7 @@ class TestRootController(TestController):
             assert_equal(wf['status'], 'error')
             assert_equal(
                 wf['message'],
-                'Page creation rate limit exceeded. Please try again later.')
+                'Page create/edit rate limit exceeded. Please try again later.')
 
     def test_rate_limit_update(self):
         # Set rate limit to unlimit
@@ -826,10 +826,35 @@ class TestRootController(TestController):
             assert_equal(wf['status'], 'error')
             assert_equal(
                 wf['message'],
-                'Page creation rate limit exceeded. Please try again later.')
+                'Page create/edit rate limit exceeded. Please try again later.')
             p = model.Page.query.get(title='page2')
             assert_equal(p, None)
 
+    def test_rate_limit_by_user(self):
+        # also test that multiple edits to a page counts as one page towards the limit
+
+        # test/wiki/Home and test/sub1/wiki already were created by this user
+        # and proactively get the user-project wiki created (otherwise it'll be created during the subsequent edits)
+        self.app.get('/u/test-admin/wiki/')
+        with h.push_config(config, **{'forgewiki.rate_limits_per_user': '{"3600": 5}'}):
+            r = self.app.post('/p/test/wiki/page123/update',  # page 4 (remember, 3 other projects' wiki pages)
+                              dict(text='Starting a new page, ok', title='page123'))
+            assert_equal(self.webflash(r), '')
+            r = self.app.post('/p/test/wiki/page123/update',
+                              dict(text='Editing some', title='page123'))
+            assert_equal(self.webflash(r), '')
+            r = self.app.post('/p/test/wiki/page123/update',
+                              dict(text='Still editing', title='page123'))
+            assert_equal(self.webflash(r), '')
+            r = self.app.post('/p/test/wiki/pageABC/update',  # page 5
+                              dict(text='Another new page', title='pageABC'))
+            assert_equal(self.webflash(r), '')
+            r = self.app.post('/p/test/wiki/pageZZZZZ/update',  # page 6
+                              dict(text='This new page hits the limit', title='pageZZZZZ'))
+            wf = json.loads(self.webflash(r))
+            assert_equal(wf['status'], 'error')
+            assert_equal(wf['message'], 'Page create/edit rate limit exceeded. Please try again later.')
+
     def test_sidebar_admin_menu(self):
         r = self.app.get('/p/test/wiki/Home/')
         menu = r.html.find('div', {'id': 'sidebar-admin-menu'})

http://git-wip-us.apache.org/repos/asf/allura/blob/f9ca424f/ForgeWiki/forgewiki/wiki_main.py
----------------------------------------------------------------------
diff --git a/ForgeWiki/forgewiki/wiki_main.py b/ForgeWiki/forgewiki/wiki_main.py
index c5325b9..2350509 100644
--- a/ForgeWiki/forgewiki/wiki_main.py
+++ b/ForgeWiki/forgewiki/wiki_main.py
@@ -534,8 +534,8 @@ class PageController(BaseController, FeedController):
             self.rate_limit()
 
     def rate_limit(self):
-        if WM.Page.is_limit_exceeded(c.app.config):
-            msg = 'Page creation rate limit exceeded. '
+        if WM.Page.is_limit_exceeded(c.app.config, user=c.user):
+            msg = 'Page create/edit rate limit exceeded. '
             log.warn(msg + c.app.config.url())
             flash(msg + 'Please try again later.', 'error')
             redirect('..')
@@ -607,6 +607,7 @@ class PageController(BaseController, FeedController):
     @without_trailing_slash
     @expose('jinja:forgewiki:templates/wiki/page_edit.html')
     def edit(self):
+        self.rate_limit()  # check before trying to save
         page_exists = self.page
         if self.page:
             require_access(self.page, 'edit')
@@ -718,6 +719,7 @@ class PageController(BaseController, FeedController):
             flash('You must provide a title for the page.', 'error')
             redirect('edit')
         title = title.replace('/', '-')
+        self.rate_limit()
         if not self.page:
             # the page doesn't exist yet, so create it
             self.page = WM.Page.upsert(self.title)
@@ -861,8 +863,8 @@ class PageRestController(BaseController):
         with h.notifications_disabled(c.project):
             if not self.page:
                 require_access(c.app, 'create')
-                if WM.Page.is_limit_exceeded(c.app.config):
-                    log.warn('Page creation rate limit exceeded. %s',
+                if WM.Page.is_limit_exceeded(c.app.config, user=c.user):
+                    log.warn('Page create/edit rate limit exceeded. %s',
                              c.app.config.url())
                     raise forge_exc.HTTPTooManyRequests()
                 self.page = WM.Page.upsert(title)

http://git-wip-us.apache.org/repos/asf/allura/blob/f9ca424f/requirements.txt
----------------------------------------------------------------------
diff --git a/requirements.txt b/requirements.txt
index 4b3945b..eff06b7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,7 +21,7 @@ httplib2==0.7.4
 iso8601==0.1.4
 Jinja2==2.8
 Markdown==2.2.0
-Ming==0.5.2
+Ming==0.5.4
 oauth2==1.5.170
 # tg2 dep PasteDeploy must specified before TurboGears2, to avoid a version/allow-hosts problem
 Paste==1.7.5.1