Browse Source

First pass on data anonimization

Rafał Pitoń 7 years ago
parent
commit
65dcd578f7

+ 5 - 0
docs/settings/Core.md

@@ -36,6 +36,11 @@ Path prefix for Misago administration backend. Defautly "admincp", but you may s
 Maximum allowed lenght of inactivity period between two requests to admin namespaces. If its exceeded, user will be asked to sign in again to admin backed before being allowed to continue activities.
 
 
+## `MISAGO_ANONYMOUS_USERNAME`
+
+Anonymous name used to replace deleted user's name in places that are keeping it. Defaults to "Ghost".
+
+
 ## `MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT`
 
 Max dimensions (width and height) of user-uploaded images embedded in posts. If uploaded image is greater than dimensions specified in this settings, Misago will generate thumbnail for it.

+ 2 - 2
misago/categories/signals.py

@@ -1,6 +1,6 @@
 from django.dispatch import Signal, receiver
 
-from misago.users.signals import username_changed
+from misago.users.signals import anonymize_user_content, username_changed
 
 from .models import Category
 
@@ -9,7 +9,7 @@ delete_category_content = Signal()
 move_category_content = Signal(providing_args=["new_category"])
 
 
-@receiver(username_changed)
+@receiver([anonymize_user_content, username_changed])
 def update_usernames(sender, **kwargs):
     Category.objects.filter(last_poster=sender).update(
         last_poster_name=sender.username,

+ 5 - 0
misago/conf/defaults.py

@@ -25,6 +25,11 @@ MISAGO_ACL_EXTENSIONS = [
 ]
 
 
+# Anonymous name used to replace deleted user's name in places that are keeping it
+
+MISAGO_ANONYMOUS_USERNAME = 'Ghost'
+
+
 # Custom markup extensions
 
 MISAGO_MARKUP_EXTENSIONS = []

+ 1 - 0
misago/core/utils.py

@@ -9,6 +9,7 @@ from django.utils.encoding import force_text
 from django.utils.module_loading import import_string
 
 
+ANONYMOUS_IP = '0.0.0.0'
 MISAGO_SLUGIFY = getattr(settings, 'MISAGO_SLUGIFY', 'misago.core.slugify.default')
 
 slugify = import_string(MISAGO_SLUGIFY)

+ 4 - 0
misago/threads/models/post.py

@@ -99,6 +99,10 @@ class Post(models.Model):
                 fields=['is_hidden'],
                 where={'is_hidden': False},
             ),
+            PgPartialIndex(
+                fields=['is_event', 'event_type'],
+                where={'is_event': True},
+            ),
             GinIndex(fields=['search_vector']),
         ]
 

+ 12 - 9
misago/threads/participants.py

@@ -70,8 +70,8 @@ def set_owner(thread, user):
     ThreadParticipant.objects.set_owner(thread, user)
 
 
-def change_owner(request, thread, user):
-    ThreadParticipant.objects.set_owner(thread, user)
+def change_owner(request, thread, new_owner):
+    ThreadParticipant.objects.set_owner(thread, new_owner)
     set_users_unread_private_threads_sync(
         participants=thread.participants_list,
         exclude_user=request.user,
@@ -84,8 +84,9 @@ def change_owner(request, thread, user):
             'changed_owner',
             {
                 'user': {
-                    'username': user.username,
-                    'url': user.get_absolute_url(),
+                    'id': new_owner.id,
+                    'username': new_owner.username,
+                    'url': new_owner.get_absolute_url(),
                 },
             },
         )
@@ -93,11 +94,11 @@ def change_owner(request, thread, user):
         record_event(request, thread, 'tookover')
 
 
-def add_participant(request, thread, user):
+def add_participant(request, thread, new_participant):
     """adds single participant to thread, registers this on the event"""
-    add_participants(request, thread, [user])
+    add_participants(request, thread, [new_participant])
 
-    if request.user == user:
+    if request.user == new_participant:
         record_event(request, thread, 'entered_thread')
     else:
         record_event(
@@ -106,8 +107,9 @@ def add_participant(request, thread, user):
             'added_participant',
             {
                 'user': {
-                    'username': user.username,
-                    'url': user.get_absolute_url(),
+                    'id': new_participant.id,
+                    'username': new_participant.username,
+                    'url': new_participant.get_absolute_url(),
                 },
             },
         )
@@ -191,6 +193,7 @@ def remove_participant(request, thread, user):
             event_type,
             {
                 'user': {
+                    'id': user.id,
                     'username': user.username,
                     'url': user.get_absolute_url(),
                 },

+ 74 - 2
misago/threads/signals.py

@@ -2,11 +2,13 @@ from django.contrib.auth import get_user_model
 from django.db import transaction
 from django.db.models.signals import pre_delete
 from django.dispatch import Signal, receiver
+from django.urls import reverse
 
 from misago.categories.models import Category
 from misago.categories.signals import delete_category_content, move_category_content
 from misago.core.pgutils import batch_delete, batch_update
-from misago.users.signals import delete_user_content, username_changed
+from misago.core.utils import ANONYMOUS_IP
+from misago.users.signals import anonymize_user_content, delete_user_content, username_changed
 
 from .models import Attachment, Poll, PollVote, Post, PostEdit, PostLike, Thread
 
@@ -114,7 +116,72 @@ def delete_user_threads(sender, **kwargs):
             category.save()
 
 
-@receiver(username_changed)
+@receiver([delete_user_content])
+def delete_user_in_likes(sender, **kwargs):
+    for post in sender.liked_post_set.iterator():
+        cleaned_likes = list(filter(lambda i: i['id'] != sender.id, post.last_likes))
+        if cleaned_likes != post.last_likes:
+            post.last_likes = cleaned_likes
+            post.save(update_fields=['last_likes'])
+
+
+@receiver(anonymize_user_content)
+def anonymize_user(sender, **kwargs):
+    Post.objects.filter(poster=sender).update(poster_ip=ANONYMOUS_IP)
+    PostEdit.objects.filter(editor=sender).update(editor_ip=ANONYMOUS_IP)
+    PostLike.objects.filter(liker=sender).update(liker_ip=ANONYMOUS_IP)
+
+    Attachment.objects.filter(uploader=sender).update(uploader_ip=ANONYMOUS_IP)
+
+    Poll.objects.filter(poster=sender).update(poster_ip=ANONYMOUS_IP)
+    PollVote.objects.filter(voter=sender).update(voter_ip=ANONYMOUS_IP)
+
+
+@receiver(anonymize_user_content)
+def anonymize_user_in_events(sender, **kwargs):
+    queryset = Post.objects.filter(
+        is_event=True,
+        event_type__in=[
+            'added_participant',
+            'changed_owner',
+            'owner_left',
+            'removed_owner',
+            'participant_left',
+            'removed_participant',
+        ],
+        event_context__user__id=sender.id,
+    ).iterator()
+
+    for event in queryset:
+        event.event_context = {
+            'user': {
+                'id': None,
+                'username': sender.username,
+                'url': reverse('misago:users'),
+            },
+        }
+        event.save(update_fields=['event_context'])
+
+
+@receiver([anonymize_user_content])
+def anonymize_user_in_likes(sender, **kwargs):
+    for post in sender.liked_post_set.iterator():
+        cleaned_likes = []
+        for like in post.last_likes:
+            if like['id'] == sender.id:
+                cleaned_likes.append({
+                    'id': None,
+                    'username': sender.username
+                })
+            else:
+                cleaned_likes.append(like)
+
+        if cleaned_likes != post.last_likes:
+            post.last_likes = cleaned_likes
+            post.save(update_fields=['last_likes'])
+
+
+@receiver([anonymize_user_content, username_changed])
 def update_usernames(sender, **kwargs):
     Thread.objects.filter(starter=sender).update(
         starter_name=sender.username,
@@ -125,6 +192,11 @@ def update_usernames(sender, **kwargs):
         last_poster_name=sender.username,
         last_poster_slug=sender.slug,
     )
+    
+    Thread.objects.filter(best_answer_marked_by=sender).update(
+        best_answer_marked_by_name=sender.username,
+        best_answer_marked_by_slug=sender.slug,
+    )
 
     Post.objects.filter(poster=sender).update(
         poster_name=sender.username,

+ 211 - 0
misago/threads/tests/test_anonymize_json_fields.py

@@ -0,0 +1,211 @@
+from django.contrib.auth import get_user_model
+from django.test import RequestFactory
+from django.urls import reverse
+
+from misago.categories.models import Category
+from misago.conf import settings
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from misago.threads import testutils
+from misago.threads.api.postendpoints.patch_post import patch_is_liked
+from misago.threads.models import Post
+from misago.threads.participants import (
+    add_participant, change_owner, make_participants_aware, remove_participant, set_owner)
+
+
+UserModel = get_user_model()
+
+
+def get_mock_user():
+    seed = UserModel.objects.count() + 1
+    return UserModel.objects.create_user('bob%s' % seed, 'user%s@test.com' % seed, 'Pass.123')
+
+
+class AnonymizeEventsTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(AnonymizeEventsTests, self).setUp()
+        self.factory = RequestFactory()
+
+        category = Category.objects.get(slug='first-category')
+        self.thread = testutils.post_thread(category)
+
+    def get_request(self, user=None):
+        request = self.factory.get('/customer/details')
+        request.user = user or self.user
+        request.user_ip = '127.0.0.1'
+
+        request.include_frontend_context = False
+        request.frontend_context = {}
+
+        return request
+
+    def test_anonymize_changed_owner_event(self):
+        """changed owner event is anonymized by user.anonymize_content"""
+        user = get_mock_user()
+        request = self.get_request()
+
+        set_owner(self.thread, self.user)
+        make_participants_aware(self.user, self.thread)
+        change_owner(request, self.thread, user)
+
+        user.anonymize_content()
+
+        event = Post.objects.get(event_type='changed_owner')
+        self.assertEqual(event.event_context, {
+            'user': {
+                'id': None,
+                'username': user.username,
+                'url': reverse('misago:users'),
+            },
+        })
+
+    def test_anonymize_added_participant_event(self):
+        """added participant event is anonymized by user.anonymize_content"""
+        user = get_mock_user()
+        request = self.get_request()
+
+        set_owner(self.thread, self.user)
+        make_participants_aware(self.user, self.thread)
+        add_participant(request, self.thread, user)
+
+        user.anonymize_content()
+
+        event = Post.objects.get(event_type='added_participant')
+        self.assertEqual(event.event_context, {
+            'user': {
+                'id': None,
+                'username': user.username,
+                'url': reverse('misago:users'),
+            },
+        })
+
+    def test_anonymize_owner_left_event(self):
+        """owner left event is anonymized by user.anonymize_content"""
+        user = get_mock_user()
+        request = self.get_request(user)
+
+        set_owner(self.thread, user)
+        make_participants_aware(user, self.thread)
+        add_participant(request, self.thread, self.user)
+
+        make_participants_aware(user, self.thread)
+        remove_participant(request, self.thread, user)
+
+        user.anonymize_content()
+
+        event = Post.objects.get(event_type='owner_left')
+        self.assertEqual(event.event_context, {
+            'user': {
+                'id': None,
+                'username': user.username,
+                'url': reverse('misago:users'),
+            },
+        })
+
+    def test_anonymize_removed_owner_event(self):
+        """removed owner event is anonymized by user.anonymize_content"""
+        user = get_mock_user()
+        request = self.get_request()
+
+        set_owner(self.thread, user)
+        make_participants_aware(user, self.thread)
+        add_participant(request, self.thread, self.user)
+        
+        make_participants_aware(user, self.thread)
+        remove_participant(request, self.thread, user)
+
+        user.anonymize_content()
+
+        event = Post.objects.get(event_type='removed_owner')
+        self.assertEqual(event.event_context, {
+            'user': {
+                'id': None,
+                'username': user.username,
+                'url': reverse('misago:users'),
+            },
+        })
+
+    def test_anonymize_participant_left_event(self):
+        """participant left event is anonymized by user.anonymize_content"""
+        user = get_mock_user()
+        request = self.get_request(user)
+
+        set_owner(self.thread, self.user)
+        make_participants_aware(user, self.thread)
+        add_participant(request, self.thread, user)
+
+        make_participants_aware(user, self.thread)
+        remove_participant(request, self.thread, user)
+
+        user.anonymize_content()
+
+        event = Post.objects.get(event_type='participant_left')
+        self.assertEqual(event.event_context, {
+            'user': {
+                'id': None,
+                'username': user.username,
+                'url': reverse('misago:users'),
+            },
+        })
+        
+    def test_anonymize_removed_participant_event(self):
+        """removed participant event is anonymized by user.anonymize_content"""
+        user = get_mock_user()
+        request = self.get_request()
+
+        set_owner(self.thread, self.user)
+        make_participants_aware(self.user, self.thread)
+        add_participant(request, self.thread, user)
+
+        make_participants_aware(self.user, self.thread)
+        remove_participant(request, self.thread, user)
+
+        user.anonymize_content()
+
+        event = Post.objects.get(event_type='removed_participant')
+        self.assertEqual(event.event_context, {
+            'user': {
+                'id': None,
+                'username': user.username,
+                'url': reverse('misago:users'),
+            },
+        })
+
+
+class AnonymizeLikesTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(AnonymizeLikesTests, self).setUp()
+        self.factory = RequestFactory()
+
+    def get_request(self, user=None):
+        request = self.factory.get('/customer/details')
+        request.user = user or self.user
+        request.user_ip = '127.0.0.1'
+
+        return request
+
+    def test_anonymize_user_likes(self):
+        """post's last like is anonymized by user.anonymize_content"""
+        category = Category.objects.get(slug='first-category')
+        thread = testutils.post_thread(category)
+        post = testutils.reply_thread(thread)
+        post.acl = {'can_like': True}
+
+        user = get_mock_user()
+
+        patch_is_liked(self.get_request(self.user), post, 1)
+        patch_is_liked(self.get_request(user), post, 1)
+
+        user.anonymize_content()
+
+        last_likes = Post.objects.get(pk=post.pk).last_likes
+        self.assertEqual(last_likes, [
+            {
+                'id': None,
+                'username': user.username,
+            },
+            {
+                'id': self.user.id,
+                'username': self.user.username,
+            },
+        ])

+ 10 - 0
misago/users/models/user.py

@@ -295,6 +295,8 @@ class User(AbstractBaseUser, PermissionsMixin):
         if kwargs.pop('delete_content', False):
             self.delete_content()
 
+        self.anonymize_content()
+
         avatars.delete_avatar(self)
 
         return super(User, self).delete(*args, **kwargs)
@@ -303,6 +305,14 @@ class User(AbstractBaseUser, PermissionsMixin):
         from misago.users.signals import delete_user_content
         delete_user_content.send(sender=self)
 
+    def anonymize_content(self):
+        # Replace username on associated items with anonymous one
+        self.username = settings.MISAGO_ANONYMOUS_USERNAME
+        self.slug = slugify(self.username)
+        
+        from misago.users.signals import anonymize_user_content
+        anonymize_user_content.send(sender=self)
+
     @property
     def acl_cache(self):
         try:

+ 1 - 0
misago/users/signals.py

@@ -1,6 +1,7 @@
 from django.dispatch import Signal, receiver
 
 
+anonymize_user_content = Signal()
 delete_user_content = Signal()
 username_changed = Signal()
 

+ 11 - 0
misago/users/tests/test_user_model.py

@@ -2,6 +2,9 @@
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
+from misago.conf import settings
+from misago.core.utils import slugify
+
 from misago.users.models import User
 
 
@@ -87,3 +90,11 @@ class UserModelTests(TestCase):
         user.set_email('bOb@TEst.com')
         self.assertEqual(user.email, 'bOb@test.com')
         self.assertTrue(user.email_hash)
+
+    def test_anonymize_content(self):
+        """anonymize_content sets username and slug to one defined in settings"""
+        user = User.objects.create_user('Bob', 'bob@example.com', 'Pass.123')
+
+        user.anonymize_content()
+        self.assertEqual(user.username, settings.MISAGO_ANONYMOUS_USERNAME)
+        self.assertEqual(user.slug, slugify(settings.MISAGO_ANONYMOUS_USERNAME))