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 2026/09/17 17:56:10 UTC

(allura) 03/15: [#8624] remove Feed entries when content is deleted or restricted

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch db/8624b
in repository https://gitbox.apache.org/repos/asf/allura.git

commit 1f9605af1e058796decd7daacdcea9db41d312fa
Author: Dave Brondsema <db...@slashdotmedia.com>
AuthorDate: Tue Sep 8 16:50:43 2026 +0000

    [#8624] remove Feed entries when content is deleted or restricted
    
    Feed entries keep a copy of the artifact's title and body and are served
    without re-checking the artifact, but nothing removed them once created.
    Artifact.delete() now clears them, and a revoke_feed_entries() hook is
    called from the tracker/wiki/comment soft-delete and spam paths and when a
    ticket is made private. ForgeBlog already did this in BlogPost.delete().
    
    Co-Authored-By: Claude Opus 5 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CbUqCrWrninjwW3vSrxxbC
---
 Allura/allura/model/artifact.py              |  9 +++++
 Allura/allura/model/discuss.py               | 15 +++++++-
 Allura/allura/tests/functional/test_feeds.py | 56 ++++++++++++++++++++++++++++
 ForgeTracker/forgetracker/model/ticket.py    |  2 +
 ForgeWiki/forgewiki/model/wiki.py            |  1 +
 5 files changed, 82 insertions(+), 1 deletion(-)

diff --git a/Allura/allura/model/artifact.py b/Allura/allura/model/artifact.py
index 1ba02de73..6ee7dace9 100644
--- a/Allura/allura/model/artifact.py
+++ b/Allura/allura/model/artifact.py
@@ -126,6 +126,10 @@ def __json__(self, posts_limit=None, is_export=False, user=None):
                                            self.discussion_thread.url()),
         )
 
+    def revoke_feed_entries(self):
+        '''Drop feed entries, for soft-deletion or when access is narrowed.'''
+        Feed.remove_for(self)
+
     def parent_security_context(self):
         """Return the :class:`allura.model.project.AppConfig` instance for
         this Artifact.
@@ -500,6 +504,7 @@ def delete(self):
         idx_id = self.index_id()
         ArtifactReference.query.remove(dict(_id=idx_id))
         Shortlink.query.remove(dict(ref_id=idx_id))
+        self.revoke_feed_entries()  # Post overrides this; its entries aren't filed under its own ref_id
         super().delete()
         session(self).flush(self)
         session(self).imap.expunge(self)
@@ -974,6 +979,10 @@ def clear_user_data(self):
     def from_username(cls, username):
         return cls.query.find({'author_link': f"/u/{username}/"}).all()
 
+    @classmethod
+    def remove_for(cls, artifact):
+        cls.query.remove(dict(ref_id=artifact.index_id()))
+
     @classmethod
     def has_access(cls, artifact):
         # Enable only for development.
diff --git a/Allura/allura/model/discuss.py b/Allura/allura/model/discuss.py
index 579a32aa6..f8f341fc8 100644
--- a/Allura/allura/model/discuss.py
+++ b/Allura/allura/model/discuss.py
@@ -291,7 +291,7 @@ def primary(self):
         return self.ref.artifact
 
     def post_to_feed(self, post):
-        if post.status == 'ok':
+        if post.status == 'ok' and not post.deleted:
             Feed.post(
                 self.primary(),
                 title=post.subject,
@@ -742,6 +742,16 @@ def traverse(posts):
             return f'{url}?limit={limit}#{slug}'
         return f'{url}?limit={limit}&page={page}#{slug}'
 
+    def revoke_feed_entries(self):
+        '''post_to_feed() files these under the thread's artifact, not the post.'''
+        primary = self.thread.primary() if self.thread else None
+        if primary is None:  # pragma no cover
+            return
+        suffix = '#' + h.urlquote(self.slug)  # the page number in the link can shift
+        for item in Feed.query.find(dict(ref_id=primary.index_id())):
+            if item.link and item.link.endswith(suffix):
+                item.delete()
+
     def shorthand_id(self):
         if self.thread:
             return f'{self.thread.shorthand_id()}#{self.slug}'
@@ -764,6 +774,7 @@ def delete(self):
 
     def soft_delete(self):
         self.deleted = True
+        self.revoke_feed_entries()
         session(self).flush(self)
         self.thread.update_stats()
 
@@ -836,6 +847,7 @@ def notify(self, file_info=None, notification_text=None):
 
     def spam(self, submit_spam_feedback=True):
         self.status = 'spam'
+        self.revoke_feed_entries()
         if submit_spam_feedback:
             g.spam_checker.submit_spam(self.text, artifact=self, user=self.author())
         session(self).flush(self)
@@ -844,6 +856,7 @@ def spam(self, submit_spam_feedback=True):
     def undo(self, prev_status):
         if prev_status in ('ok', 'pending'):
             self.status = prev_status
+            self.thread.post_to_feed(self)  # spam() revoked it
             session(self).flush(self)
             self.thread.update_stats()
 
diff --git a/Allura/allura/tests/functional/test_feeds.py b/Allura/allura/tests/functional/test_feeds.py
index 9949b5976..a23c06e07 100644
--- a/Allura/allura/tests/functional/test_feeds.py
+++ b/Allura/allura/tests/functional/test_feeds.py
@@ -16,10 +16,18 @@
 #       under the License.
 
 from formencode.variabledecode import variable_encode
+from ming.odm import ThreadLocalODMSession
 
+from tg import tmpl_context as c
+
+from allura import model as M
 from allura.tests import TestController
 from allura.tests import decorators as td
 from allura.lib import helpers as h
+from forgetracker import model as TM
+from forgewiki import model as WM
+
+ANON = dict(username='*anonymous')
 
 
 class TestFeeds(TestController):
@@ -80,6 +88,54 @@ def test_ticket_list_feed(self):
         self.app.get('/bugs/feed.rss')
         self.app.get('/bugs/feed.atom')
 
+    @td.with_tracker
+    @td.with_tracker
+    def test_ticket_feed_drops_privatised_ticket(self):
+        assert 'This is a ticket' in self.app.get('/bugs/feed.rss', extra_environ=ANON)
+        TM.Ticket.query.get(ticket_num=1).private = True
+        ThreadLocalODMSession.flush_all()
+        r = self.app.get('/bugs/feed.rss', extra_environ=ANON)
+        assert 'This is a ticket' not in r
+        assert 'This is a description' not in r
+
+    @td.with_wiki
+    def test_wiki_feed_drops_deleted_page(self):
+        self.app.post('/wiki/Doomed/update',
+                      params={'title': 'Doomed', 'text': 'sekrit', 'labels': ''})
+        assert 'sekrit' in self.app.get('/wiki/feed.rss', extra_environ=ANON)
+        self.app.post('/wiki/Doomed/delete')
+        assert 'sekrit' not in self.app.get('/wiki/feed.rss', extra_environ=ANON)
+
+    @td.with_wiki
+    def test_wiki_feed_drops_spammed_comment(self):
+        h.set_context('test', 'wiki', neighborhood='Projects')
+        page = WM.Page.query.get(title='Home', app_config_id=c.app.config._id)
+        page.discussion_thread.add_post(text='spammy comment')
+        ThreadLocalODMSession.flush_all()
+        assert 'spammy comment' in self.app.get('/wiki/feed.rss', extra_environ=ANON)
+
+        M.Post.query.get(text='spammy comment').spam(submit_spam_feedback=False)
+        ThreadLocalODMSession.flush_all()
+        assert 'spammy comment' not in self.app.get('/wiki/feed.rss', extra_environ=ANON)
+
+        h.set_context('test', 'wiki', neighborhood='Projects')
+        M.Post.query.get(text='spammy comment').undo('ok')
+        ThreadLocalODMSession.flush_all()
+        assert 'spammy comment' in self.app.get('/wiki/feed.rss', extra_environ=ANON)
+
+    @td.with_wiki
+    def test_wiki_feed_drops_hard_deleted_comment(self):
+        h.set_context('test', 'wiki', neighborhood='Projects')
+        page = WM.Page.query.get(title='Home', app_config_id=c.app.config._id)
+        page.discussion_thread.add_post(text='doomed comment')
+        ThreadLocalODMSession.flush_all()
+        assert 'doomed comment' in self.app.get('/wiki/feed.rss', extra_environ=ANON)
+
+        h.set_context('test', 'wiki', neighborhood='Projects')
+        M.Post.query.get(text='doomed comment').delete()
+        ThreadLocalODMSession.flush_all()
+        assert 'doomed comment' not in self.app.get('/wiki/feed.rss', extra_environ=ANON)
+
     @td.with_tracker
     def test_ticket_feed(self):
         self.app.get('/bugs/1/feed.rss')
diff --git a/ForgeTracker/forgetracker/model/ticket.py b/ForgeTracker/forgetracker/model/ticket.py
index 0434ba2b0..73f3caac1 100644
--- a/ForgeTracker/forgetracker/model/ticket.py
+++ b/ForgeTracker/forgetracker/model/ticket.py
@@ -895,6 +895,7 @@ def _allow_all(role, perms):
                 acl += _allow_all(role_creator, security.all_allowed(self, role_creator))
             acl += [DENY_ALL]
             self.acl = acl
+            self.revoke_feed_entries()
         else:
             self.acl = []
     private = property(_get_private, _set_private)
@@ -1399,6 +1400,7 @@ def delete(self):
     def soft_delete(self):
         require_access(self, 'delete')
         Shortlink.query.remove(dict(ref_id=self.index_id()))
+        self.revoke_feed_entries()
         self.deleted = True
         suffix = " {dt.hour}:{dt.minute}:{dt.second} {dt.day}-{dt.month}-{dt.year}".format(
             dt=datetime.utcnow())
diff --git a/ForgeWiki/forgewiki/model/wiki.py b/ForgeWiki/forgewiki/model/wiki.py
index 91da0ca5e..7da34efc4 100644
--- a/ForgeWiki/forgewiki/model/wiki.py
+++ b/ForgeWiki/forgewiki/model/wiki.py
@@ -281,6 +281,7 @@ def soft_delete(self):
         Notification.post(
             artifact=self, topic='metadata', text=description, subject=subject)
         Shortlink.query.remove(dict(ref_id=self.index_id()))
+        self.revoke_feed_entries()
         self.deleted = True
         suffix = f" {datetime.utcnow():%Y-%m-%d %H:%M:%S.%f}"
         self.title += suffix