diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index 2483cc62b..2fe5d825c 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -219,6 +219,12 @@ def dict_from_mappings(data, mappings):
def get_data(url, params=None):
""" wrapper for request.get """
+ # check if the url is blocked
+ if models.FederatedServer.is_blocked(url):
+ raise ConnectorException(
+ "Attempting to load data from blocked url: {:s}".format(url)
+ )
+
try:
resp = requests.get(
url,
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index b159a89ef..7c41323c0 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -281,3 +281,9 @@ class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "statuses", "note"]
+
+
+class ServerForm(CustomForm):
+ class Meta:
+ model = models.FederatedServer
+ exclude = ["remote_id"]
diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py
index d6101c877..a86a1652e 100644
--- a/bookwyrm/management/commands/initdb.py
+++ b/bookwyrm/management/commands/initdb.py
@@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
-from bookwyrm.models import Connector, SiteSettings, User
+from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
from bookwyrm.settings import DOMAIN
@@ -107,6 +107,16 @@ def init_connectors():
)
+def init_federated_servers():
+ """ big no to nazis """
+ built_in_blocks = ["gab.ai", "gab.com"]
+ for server in built_in_blocks:
+ FederatedServer.objects.create(
+ server_name=server,
+ status="blocked",
+ )
+
+
def init_settings():
SiteSettings.objects.create()
@@ -118,4 +128,5 @@ class Command(BaseCommand):
init_groups()
init_permissions()
init_connectors()
+ init_federated_servers()
init_settings()
diff --git a/bookwyrm/migrations/0063_auto_20210407_1827.py b/bookwyrm/migrations/0063_auto_20210407_1827.py
new file mode 100644
index 000000000..0bd0f2ae4
--- /dev/null
+++ b/bookwyrm/migrations/0063_auto_20210407_1827.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.1.6 on 2021-04-07 18:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0062_auto_20210407_1545"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="federatedserver",
+ name="notes",
+ field=models.TextField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name="federatedserver",
+ name="application_type",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name="federatedserver",
+ name="application_version",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name="federatedserver",
+ name="status",
+ field=models.CharField(
+ choices=[("federated", "Federated"), ("blocked", "Blocked")],
+ default="federated",
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0064_merge_20210410_1633.py b/bookwyrm/migrations/0064_merge_20210410_1633.py
new file mode 100644
index 000000000..77ad541e9
--- /dev/null
+++ b/bookwyrm/migrations/0064_merge_20210410_1633.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.1.8 on 2021-04-10 16:33
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0063_auto_20210408_1556"),
+ ("bookwyrm", "0063_auto_20210407_1827"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0065_merge_20210411_1702.py b/bookwyrm/migrations/0065_merge_20210411_1702.py
new file mode 100644
index 000000000..2bdc425dc
--- /dev/null
+++ b/bookwyrm/migrations/0065_merge_20210411_1702.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.1.8 on 2021-04-11 17:02
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0064_auto_20210408_2208"),
+ ("bookwyrm", "0064_merge_20210410_1633"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0066_user_deactivation_reason.py b/bookwyrm/migrations/0066_user_deactivation_reason.py
new file mode 100644
index 000000000..bb3173a7c
--- /dev/null
+++ b/bookwyrm/migrations/0066_user_deactivation_reason.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1.8 on 2021-04-12 15:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0065_merge_20210411_1702"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="deactivation_reason",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("self_deletion", "Self Deletion"),
+ ("moderator_deletion", "Moderator Deletion"),
+ ("domain_block", "Domain Block"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index d0ab829d9..ce16460e6 100644
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -153,7 +153,7 @@ class ActivitypubMixin:
# unless it's a dm, all the followers should receive the activity
if privacy != "direct":
# we will send this out to a subset of all remote users
- queryset = user_model.objects.filter(
+ queryset = user_model.viewer_aware_objects(user).filter(
local=False,
)
# filter users first by whether they're using the desired software
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index cb2fc851e..261c96868 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -31,6 +31,36 @@ class BookWyrmModel(models.Model):
""" how to link to this object in the local app """
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
+ def visible_to_user(self, viewer):
+ """ is a user authorized to view an object? """
+ # make sure this is an object with privacy owned by a user
+ if not hasattr(self, "user") or not hasattr(self, "privacy"):
+ return None
+
+ # viewer can't see it if the object's owner blocked them
+ if viewer in self.user.blocks.all():
+ return False
+
+ # you can see your own posts and any public or unlisted posts
+ if viewer == self.user or self.privacy in ["public", "unlisted"]:
+ return True
+
+ # you can see the followers only posts of people you follow
+ if (
+ self.privacy == "followers"
+ and self.user.followers.filter(id=viewer.id).first()
+ ):
+ return True
+
+ # you can see dms you are tagged in
+ if hasattr(self, "mention_users"):
+ if (
+ self.privacy == "direct"
+ and self.mention_users.filter(id=viewer.id).first()
+ ):
+ return True
+ return False
+
@receiver(models.signals.post_save)
# pylint: disable=unused-argument
diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py
index 8f7d903e4..aa2b2f6af 100644
--- a/bookwyrm/models/federated_server.py
+++ b/bookwyrm/models/federated_server.py
@@ -1,17 +1,51 @@
""" connections to external ActivityPub servers """
+from urllib.parse import urlparse
from django.db import models
from .base_model import BookWyrmModel
+FederationStatus = models.TextChoices(
+ "Status",
+ [
+ "federated",
+ "blocked",
+ ],
+)
+
class FederatedServer(BookWyrmModel):
""" store which servers we federate with """
server_name = models.CharField(max_length=255, unique=True)
- # federated, blocked, whatever else
- status = models.CharField(max_length=255, default="federated")
+ status = models.CharField(
+ max_length=255, default="federated", choices=FederationStatus.choices
+ )
# is it mastodon, bookwyrm, etc
- application_type = models.CharField(max_length=255, null=True)
- application_version = models.CharField(max_length=255, null=True)
+ application_type = models.CharField(max_length=255, null=True, blank=True)
+ application_version = models.CharField(max_length=255, null=True, blank=True)
+ notes = models.TextField(null=True, blank=True)
+ def block(self):
+ """ block a server """
+ self.status = "blocked"
+ self.save()
-# TODO: blocked servers
+ # deactivate all associated users
+ self.user_set.filter(is_active=True).update(
+ is_active=False, deactivation_reason="domain_block"
+ )
+
+ def unblock(self):
+ """ unblock a server """
+ self.status = "federated"
+ self.save()
+
+ self.user_set.filter(deactivation_reason="domain_block").update(
+ is_active=True, deactivation_reason=None
+ )
+
+ @classmethod
+ def is_blocked(cls, url):
+ """ look up if a domain is blocked """
+ url = urlparse(url)
+ domain = url.netloc
+ return cls.objects.filter(server_name=domain, status="blocked").exists()
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index c519f76c9..15ceb19bd 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -24,6 +24,16 @@ from .federated_server import FederatedServer
from . import fields, Review
+DeactivationReason = models.TextChoices(
+ "DeactivationReason",
+ [
+ "self_deletion",
+ "moderator_deletion",
+ "domain_block",
+ ],
+)
+
+
class User(OrderedCollectionPageMixin, AbstractUser):
""" a user who wants to read books """
@@ -111,6 +121,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
default=str(pytz.utc),
max_length=255,
)
+ deactivation_reason = models.CharField(
+ max_length=255, choices=DeactivationReason.choices, null=True, blank=True
+ )
name_field = "username"
property_fields = [("following_link", "following")]
@@ -138,7 +151,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def viewer_aware_objects(cls, viewer):
""" the user queryset filtered for the context of the logged in user """
queryset = cls.objects.filter(is_active=True)
- if viewer.is_authenticated:
+ if viewer and viewer.is_authenticated:
queryset = queryset.exclude(blocks=viewer)
return queryset
diff --git a/bookwyrm/templates/settings/admin_layout.html b/bookwyrm/templates/settings/admin_layout.html
index 9340da9e1..4f71a2284 100644
--- a/bookwyrm/templates/settings/admin_layout.html
+++ b/bookwyrm/templates/settings/admin_layout.html
@@ -6,7 +6,14 @@
{% block content %}
diff --git a/bookwyrm/templates/settings/edit_server.html b/bookwyrm/templates/settings/edit_server.html
new file mode 100644
index 000000000..6ae227898
--- /dev/null
+++ b/bookwyrm/templates/settings/edit_server.html
@@ -0,0 +1,58 @@
+{% extends 'settings/admin_layout.html' %}
+{% load i18n %}
+{% block title %}{% trans "Add server" %}{% endblock %}
+
+{% block header %}
+{% trans "Add server" %}
+
{% trans "Back to server list" %}
+{% endblock %}
+
+{% block panel %}
+
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/settings/federated_server.html b/bookwyrm/templates/settings/federated_server.html
index 13715bfb2..6996557d8 100644
--- a/bookwyrm/templates/settings/federated_server.html
+++ b/bookwyrm/templates/settings/federated_server.html
@@ -4,64 +4,112 @@
{% block header %}
{{ server.server_name }}
+
+{% if server.status == "blocked" %}
{% trans "Blocked" %}
+{% endif %}
+
{% trans "Back to server list" %}
{% endblock %}
{% block panel %}
+
+
+ {% trans "Details" %}
+
+
+
- {% trans "Software:" %}
+ - {{ server.application_type }}
+
+
+
- {% trans "Version:" %}
+ - {{ server.application_version }}
+
+
+
- {% trans "Status:" %}
+ - {{ server.status }}
+
+
+
+
+
+ {% trans "Activity" %}
+
+
+
- {% trans "Users:" %}
+
-
+ {{ users.count }}
+ {% if server.user_set.count %}({% trans "View all" %}){% endif %}
+
+
+
+
- {% trans "Reports:" %}
+
-
+ {{ reports.count }}
+ {% if reports.count %}({% trans "View all" %}){% endif %}
+
+
+
+
- {% trans "Followed by us:" %}
+ -
+ {{ followed_by_us.count }}
+
+
+
+
- {% trans "Followed by them:" %}
+ -
+ {{ followed_by_them.count }}
+
+
+
+
- {% trans "Blocked by us:" %}
+ -
+ {{ blocked_by_us.count }}
+
+
+
+
+
+
- {% trans "Details" %}
-
-
-
- {% trans "Software:" %}
-
- {{ server.application_type }}
+
+
+
{% trans "Notes" %}
-
-
- {% trans "Version:" %}
-
- {{ server.application_version }}
+
+ {% trans "Edit" as button_text %}
+ {% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
-
-
- {% trans "Status:" %}
- - Federated
-
-
+
+ {% if server.notes %}
+
{{ server.notes }}
+ {% endif %}
+
- {% trans "Activity" %}
-
-
-
- {% trans "Users:" %}
-
-
- {{ users.count }}
- {% if server.user_set.count %}({% trans "View all" %}){% endif %}
-
-
-
-
- {% trans "Reports:" %}
-
-
- {{ reports.count }}
- {% if reports.count %}({% trans "View all" %}){% endif %}
-
-
-
-
- {% trans "Followed by us:" %}
- -
- {{ followed_by_us.count }}
-
-
-
-
- {% trans "Followed by them:" %}
- -
- {{ followed_by_them.count }}
-
-
-
-
- {% trans "Blocked by us:" %}
- -
- {{ blocked_by_us.count }}
-
-
-
+ {% trans "Actions" %}
+ {% if server.status != 'blocked' %}
+
+ {% else %}
+
+ {% endif %}
{% endblock %}
diff --git a/bookwyrm/templates/settings/federation.html b/bookwyrm/templates/settings/federation.html
index 696d7a205..99afb5418 100644
--- a/bookwyrm/templates/settings/federation.html
+++ b/bookwyrm/templates/settings/federation.html
@@ -4,8 +4,15 @@
{% block header %}{% trans "Federated Servers" %}{% endblock %}
-{% block panel %}
+{% block edit-button %}
+
+
+ {% trans "Add server" %}
+
+
+{% endblock %}
+{% block panel %}
{% url 'settings-federation' as url %}
diff --git a/bookwyrm/tests/models/test_base_model.py b/bookwyrm/tests/models/test_base_model.py
index 25a2e7ee6..442f98ca1 100644
--- a/bookwyrm/tests/models/test_base_model.py
+++ b/bookwyrm/tests/models/test_base_model.py
@@ -1,4 +1,5 @@
""" testing models """
+from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models
@@ -9,6 +10,22 @@ from bookwyrm.settings import DOMAIN
class BaseModel(TestCase):
""" functionality shared across models """
+ def setUp(self):
+ """ shared data """
+ self.local_user = models.User.objects.create_user(
+ "mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse"
+ )
+ with patch("bookwyrm.models.user.set_remote_server.delay"):
+ self.remote_user = models.User.objects.create_user(
+ "rat",
+ "rat@rat.com",
+ "ratword",
+ local=False,
+ remote_id="https://example.com/users/rat",
+ inbox="https://example.com/users/rat/inbox",
+ outbox="https://example.com/users/rat/outbox",
+ )
+
def test_remote_id(self):
""" these should be generated """
instance = base_model.BookWyrmModel()
@@ -18,11 +35,8 @@ class BaseModel(TestCase):
def test_remote_id_with_user(self):
""" format of remote id when there's a user object """
- user = models.User.objects.create_user(
- "mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse"
- )
instance = base_model.BookWyrmModel()
- instance.user = user
+ instance.user = self.local_user
instance.id = 1
expected = instance.get_remote_id()
self.assertEqual(expected, "https://%s/user/mouse/bookwyrmmodel/1" % DOMAIN)
@@ -42,3 +56,66 @@ class BaseModel(TestCase):
instance.remote_id = None
base_model.set_remote_id(None, instance, False)
self.assertIsNone(instance.remote_id)
+
+ @patch("bookwyrm.activitystreams.ActivityStream.add_status")
+ def test_object_visible_to_user(self, _):
+ """ does a user have permission to view an object """
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="public"
+ )
+ self.assertTrue(obj.visible_to_user(self.local_user))
+
+ obj = models.Shelf.objects.create(
+ name="test", user=self.remote_user, privacy="unlisted"
+ )
+ self.assertTrue(obj.visible_to_user(self.local_user))
+
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="followers"
+ )
+ self.assertFalse(obj.visible_to_user(self.local_user))
+
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="direct"
+ )
+ self.assertFalse(obj.visible_to_user(self.local_user))
+
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="direct"
+ )
+ obj.mention_users.add(self.local_user)
+ self.assertTrue(obj.visible_to_user(self.local_user))
+
+ @patch("bookwyrm.activitystreams.ActivityStream.add_status")
+ def test_object_visible_to_user_follower(self, _):
+ """ what you can see if you follow a user """
+ self.remote_user.followers.add(self.local_user)
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="followers"
+ )
+ self.assertTrue(obj.visible_to_user(self.local_user))
+
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="direct"
+ )
+ self.assertFalse(obj.visible_to_user(self.local_user))
+
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="direct"
+ )
+ obj.mention_users.add(self.local_user)
+ self.assertTrue(obj.visible_to_user(self.local_user))
+
+ @patch("bookwyrm.activitystreams.ActivityStream.add_status")
+ def test_object_visible_to_user_blocked(self, _):
+ """ you can't see it if they block you """
+ self.remote_user.blocks.add(self.local_user)
+ obj = models.Status.objects.create(
+ content="hi", user=self.remote_user, privacy="public"
+ )
+ self.assertFalse(obj.visible_to_user(self.local_user))
+
+ obj = models.Shelf.objects.create(
+ name="test", user=self.remote_user, privacy="unlisted"
+ )
+ self.assertFalse(obj.visible_to_user(self.local_user))
diff --git a/bookwyrm/tests/models/test_federated_server.py b/bookwyrm/tests/models/test_federated_server.py
new file mode 100644
index 000000000..4e9e8b686
--- /dev/null
+++ b/bookwyrm/tests/models/test_federated_server.py
@@ -0,0 +1,67 @@
+""" testing models """
+from unittest.mock import patch
+from django.test import TestCase
+
+from bookwyrm import models
+
+
+class FederatedServer(TestCase):
+ """ federate server management """
+
+ def setUp(self):
+ """ we'll need a user """
+ self.server = models.FederatedServer.objects.create(server_name="test.server")
+ with patch("bookwyrm.models.user.set_remote_server.delay"):
+ self.remote_user = models.User.objects.create_user(
+ "rat",
+ "rat@rat.com",
+ "ratword",
+ federated_server=self.server,
+ local=False,
+ remote_id="https://example.com/users/rat",
+ inbox="https://example.com/users/rat/inbox",
+ outbox="https://example.com/users/rat/outbox",
+ )
+ self.inactive_remote_user = models.User.objects.create_user(
+ "nutria",
+ "nutria@nutria.com",
+ "nutriaword",
+ federated_server=self.server,
+ local=False,
+ remote_id="https://example.com/users/nutria",
+ inbox="https://example.com/users/nutria/inbox",
+ outbox="https://example.com/users/nutria/outbox",
+ is_active=False,
+ deactivation_reason="self_deletion",
+ )
+
+ def test_block_unblock(self):
+ """ block a server and all users on it """
+ self.assertEqual(self.server.status, "federated")
+ self.assertTrue(self.remote_user.is_active)
+ self.assertFalse(self.inactive_remote_user.is_active)
+
+ self.server.block()
+
+ self.assertEqual(self.server.status, "blocked")
+ self.remote_user.refresh_from_db()
+ self.assertFalse(self.remote_user.is_active)
+ self.assertEqual(self.remote_user.deactivation_reason, "domain_block")
+
+ self.inactive_remote_user.refresh_from_db()
+ self.assertFalse(self.inactive_remote_user.is_active)
+ self.assertEqual(self.inactive_remote_user.deactivation_reason, "self_deletion")
+
+ # UNBLOCK
+ self.server.unblock()
+
+ self.assertEqual(self.server.status, "federated")
+ # user blocked in deactivation is reactivated
+ self.remote_user.refresh_from_db()
+ self.assertTrue(self.remote_user.is_active)
+ self.assertIsNone(self.remote_user.deactivation_reason)
+
+ # deleted user remains deleted
+ self.inactive_remote_user.refresh_from_db()
+ self.assertFalse(self.inactive_remote_user.is_active)
+ self.assertEqual(self.inactive_remote_user.deactivation_reason, "self_deletion")
diff --git a/bookwyrm/tests/views/inbox/test_inbox.py b/bookwyrm/tests/views/inbox/test_inbox.py
index 12d7a736c..50fb1ecc0 100644
--- a/bookwyrm/tests/views/inbox/test_inbox.py
+++ b/bookwyrm/tests/views/inbox/test_inbox.py
@@ -4,8 +4,9 @@ from unittest.mock import patch
from django.http import HttpResponseNotAllowed, HttpResponseNotFound
from django.test import TestCase, Client
+from django.test.client import RequestFactory
-from bookwyrm import models
+from bookwyrm import models, views
# pylint: disable=too-many-public-methods
@@ -15,6 +16,7 @@ class Inbox(TestCase):
def setUp(self):
""" basic user and book data """
self.client = Client()
+ self.factory = RequestFactory()
local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
@@ -106,3 +108,26 @@ class Inbox(TestCase):
"/inbox", json.dumps(activity), content_type="application/json"
)
self.assertEqual(result.status_code, 200)
+
+ def test_is_blocked_user_agent(self):
+ """ check for blocked servers """
+ request = self.factory.post(
+ "",
+ HTTP_USER_AGENT="http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)",
+ )
+ self.assertFalse(views.inbox.is_blocked_user_agent(request))
+
+ models.FederatedServer.objects.create(
+ server_name="mastodon.social", status="blocked"
+ )
+ self.assertTrue(views.inbox.is_blocked_user_agent(request))
+
+ def test_is_blocked_activity(self):
+ """ check for blocked servers """
+ activity = {"actor": "https://mastodon.social/user/whaatever/else"}
+ self.assertFalse(views.inbox.is_blocked_activity(activity))
+
+ models.FederatedServer.objects.create(
+ server_name="mastodon.social", status="blocked"
+ )
+ self.assertTrue(views.inbox.is_blocked_activity(activity))
diff --git a/bookwyrm/tests/views/test_federation.py b/bookwyrm/tests/views/test_federation.py
index a60ea4327..4dc5d048f 100644
--- a/bookwyrm/tests/views/test_federation.py
+++ b/bookwyrm/tests/views/test_federation.py
@@ -1,9 +1,10 @@
""" test for app action functionality """
+from unittest.mock import patch
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
-from bookwyrm import models, views
+from bookwyrm import forms, models, views
class FederationViews(TestCase):
@@ -19,6 +20,16 @@ class FederationViews(TestCase):
local=True,
localname="mouse",
)
+ with patch("bookwyrm.models.user.set_remote_server.delay"):
+ self.remote_user = models.User.objects.create_user(
+ "rat",
+ "rat@rat.com",
+ "ratword",
+ local=False,
+ remote_id="https://example.com/users/rat",
+ inbox="https://example.com/users/rat/inbox",
+ outbox="https://example.com/users/rat/outbox",
+ )
models.SiteSettings.objects.create()
def test_federation_page(self):
@@ -44,3 +55,75 @@ class FederationViews(TestCase):
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
+
+ def test_server_page_block(self):
+ """ block a server """
+ server = models.FederatedServer.objects.create(server_name="hi.there.com")
+ self.remote_user.federated_server = server
+ self.remote_user.save()
+
+ self.assertEqual(server.status, "federated")
+
+ view = views.federation.block_server
+ request = self.factory.post("")
+ request.user = self.local_user
+ request.user.is_superuser = True
+
+ view(request, server.id)
+ server.refresh_from_db()
+ self.remote_user.refresh_from_db()
+ self.assertEqual(server.status, "blocked")
+ # and the user was deactivated
+ self.assertFalse(self.remote_user.is_active)
+
+ def test_server_page_unblock(self):
+ """ unblock a server """
+ server = models.FederatedServer.objects.create(
+ server_name="hi.there.com", status="blocked"
+ )
+ self.remote_user.federated_server = server
+ self.remote_user.is_active = False
+ self.remote_user.deactivation_reason = "domain_block"
+ self.remote_user.save()
+
+ request = self.factory.post("")
+ request.user = self.local_user
+ request.user.is_superuser = True
+
+ views.federation.unblock_server(request, server.id)
+ server.refresh_from_db()
+ self.remote_user.refresh_from_db()
+ self.assertEqual(server.status, "federated")
+ # and the user was re-activated
+ self.assertTrue(self.remote_user.is_active)
+
+ def test_add_view_get(self):
+ """ there are so many views, this just makes sure it LOADS """
+ # create mode
+ view = views.AddFederatedServer.as_view()
+ request = self.factory.get("")
+ request.user = self.local_user
+ request.user.is_superuser = True
+
+ result = view(request)
+ self.assertIsInstance(result, TemplateResponse)
+ result.render()
+ self.assertEqual(result.status_code, 200)
+
+ def test_add_view_post_create(self):
+ """ create a server entry """
+ form = forms.ServerForm()
+ form.data["server_name"] = "remote.server"
+ form.data["application_type"] = "coolsoft"
+ form.data["status"] = "blocked"
+
+ view = views.AddFederatedServer.as_view()
+ request = self.factory.post("", form.data)
+ request.user = self.local_user
+ request.user.is_superuser = True
+
+ view(request)
+ server = models.FederatedServer.objects.get()
+ self.assertEqual(server.server_name, "remote.server")
+ self.assertEqual(server.application_type, "coolsoft")
+ self.assertEqual(server.status, "blocked")
diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py
index 7d2bc42c9..2e5ed82d4 100644
--- a/bookwyrm/tests/views/test_helpers.py
+++ b/bookwyrm/tests/views/test_helpers.py
@@ -146,6 +146,15 @@ class ViewsHelpers(TestCase):
self.assertIsInstance(result, models.User)
self.assertEqual(result.username, "mouse@example.com")
+ def test_user_on_blocked_server(self, _):
+ """ find a remote user using webfinger """
+ models.FederatedServer.objects.create(
+ server_name="example.com", status="blocked"
+ )
+
+ result = views.helpers.handle_remote_webfinger("@mouse@example.com")
+ self.assertIsNone(result)
+
def test_handle_reading_status_to_read(self, _):
""" posts shelve activities """
shelf = self.local_user.shelf_set.get(identifier="to-read")
@@ -190,66 +199,6 @@ class ViewsHelpers(TestCase):
)
self.assertFalse(models.GeneratedNote.objects.exists())
- def test_object_visible_to_user(self, _):
- """ does a user have permission to view an object """
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="public"
- )
- self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
-
- obj = models.Shelf.objects.create(
- name="test", user=self.remote_user, privacy="unlisted"
- )
- self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
-
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="followers"
- )
- self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
-
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="direct"
- )
- self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
-
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="direct"
- )
- obj.mention_users.add(self.local_user)
- self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
-
- def test_object_visible_to_user_follower(self, _):
- """ what you can see if you follow a user """
- self.remote_user.followers.add(self.local_user)
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="followers"
- )
- self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
-
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="direct"
- )
- self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
-
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="direct"
- )
- obj.mention_users.add(self.local_user)
- self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
-
- def test_object_visible_to_user_blocked(self, _):
- """ you can't see it if they block you """
- self.remote_user.blocks.add(self.local_user)
- obj = models.Status.objects.create(
- content="hi", user=self.remote_user, privacy="public"
- )
- self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
-
- obj = models.Shelf.objects.create(
- name="test", user=self.remote_user, privacy="unlisted"
- )
- self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
-
def test_get_annotated_users(self, _):
""" list of people you might know """
user_1 = models.User.objects.create_user(
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
index 463988065..c5c528002 100644
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -68,6 +68,21 @@ urlpatterns = [
views.FederatedServer.as_view(),
name="settings-federated-server",
),
+ re_path(
+ r"^settings/federation/(?P\d+)/block?$",
+ views.federation.block_server,
+ name="settings-federated-server-block",
+ ),
+ re_path(
+ r"^settings/federation/(?P\d+)/unblock?$",
+ views.federation.unblock_server,
+ name="settings-federated-server-unblock",
+ ),
+ re_path(
+ r"^settings/federation/add/?$",
+ views.AddFederatedServer.as_view(),
+ name="settings-add-federated-server",
+ ),
re_path(
r"^settings/invites/?$", views.ManageInvites.as_view(), name="settings-invites"
),
diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py
index d053e971b..d7bc4e130 100644
--- a/bookwyrm/views/__init__.py
+++ b/bookwyrm/views/__init__.py
@@ -5,7 +5,8 @@ from .block import Block, unblock
from .books import Book, EditBook, ConfirmEditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book
from .directory import Directory
-from .federation import Federation, FederatedServer
+from .federation import Federation, FederatedServer, AddFederatedServer
+from .federation import block_server, unblock_server
from .feed import DirectMessage, Feed, Replies, Status
from .follow import follow, unfollow
from .follow import accept_follow_request, delete_follow_request
diff --git a/bookwyrm/views/federation.py b/bookwyrm/views/federation.py
index 464a207ca..f34f7d191 100644
--- a/bookwyrm/views/federation.py
+++ b/bookwyrm/views/federation.py
@@ -1,12 +1,13 @@
""" manage federated servers """
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
+from django.views.decorators.http import require_POST
-from bookwyrm import models
+from bookwyrm import forms, models
from bookwyrm.settings import PAGE_LENGTH
@@ -30,14 +31,38 @@ class Federation(View):
sort = request.GET.get("sort")
sort_fields = ["created_date", "application_type", "server_name"]
- if sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
- servers = servers.order_by(sort)
+ if not sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
+ sort = "created_date"
+ servers = servers.order_by(sort)
paginated = Paginator(servers, PAGE_LENGTH)
- data = {"servers": paginated.page(page), "sort": sort}
+
+ data = {
+ "servers": paginated.page(page),
+ "sort": sort,
+ "form": forms.ServerForm(),
+ }
return TemplateResponse(request, "settings/federation.html", data)
+class AddFederatedServer(View):
+ """ manually add a server """
+
+ def get(self, request):
+ """ add server form """
+ data = {"form": forms.ServerForm()}
+ return TemplateResponse(request, "settings/edit_server.html", data)
+
+ def post(self, request):
+ """ add a server from the admin panel """
+ form = forms.ServerForm(request.POST)
+ if not form.is_valid():
+ data = {"form": form}
+ return TemplateResponse(request, "settings/edit_server.html", data)
+ server = form.save()
+ return redirect("settings-federated-server", server.id)
+
+
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.control_federation", raise_exception=True),
@@ -61,3 +86,32 @@ class FederatedServer(View):
),
}
return TemplateResponse(request, "settings/federated_server.html", data)
+
+ def post(self, request, server): # pylint: disable=unused-argument
+ """ update note """
+ server = get_object_or_404(models.FederatedServer, id=server)
+ server.notes = request.POST.get("notes")
+ server.save()
+ return redirect("settings-federated-server", server.id)
+
+
+@login_required
+@require_POST
+@permission_required("bookwyrm.control_federation", raise_exception=True)
+# pylint: disable=unused-argument
+def block_server(request, server):
+ """ block a server """
+ server = get_object_or_404(models.FederatedServer, id=server)
+ server.block()
+ return redirect("settings-federated-server", server.id)
+
+
+@login_required
+@require_POST
+@permission_required("bookwyrm.control_federation", raise_exception=True)
+# pylint: disable=unused-argument
+def unblock_server(request, server):
+ """ unblock a server """
+ server = get_object_or_404(models.FederatedServer, id=server)
+ server.unblock()
+ return redirect("settings-federated-server", server.id)
diff --git a/bookwyrm/views/feed.py b/bookwyrm/views/feed.py
index cda115867..d5e644343 100644
--- a/bookwyrm/views/feed.py
+++ b/bookwyrm/views/feed.py
@@ -12,7 +12,7 @@ from bookwyrm import activitystreams, forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH, STREAMS
from .helpers import get_user_from_username, privacy_filter, get_suggested_users
-from .helpers import is_api_request, is_bookwyrm_request, object_visible_to_user
+from .helpers import is_api_request, is_bookwyrm_request
# pylint: disable= no-self-use
@@ -113,7 +113,7 @@ class Status(View):
return HttpResponseNotFound()
# make sure the user is authorized to see the status
- if not object_visible_to_user(request.user, status):
+ if not status.visible_to_user(request.user):
return HttpResponseNotFound()
if is_api_request(request):
diff --git a/bookwyrm/views/goal.py b/bookwyrm/views/goal.py
index 9c4e117c6..1627d3da3 100644
--- a/bookwyrm/views/goal.py
+++ b/bookwyrm/views/goal.py
@@ -10,7 +10,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.status import create_generated_note
-from .helpers import get_user_from_username, object_visible_to_user
+from .helpers import get_user_from_username
# pylint: disable= no-self-use
@@ -26,7 +26,7 @@ class Goal(View):
if not goal and user != request.user:
return HttpResponseNotFound()
- if goal and not object_visible_to_user(request.user, goal):
+ if goal and not goal.visible_to_user(request.user):
return HttpResponseNotFound()
data = {
diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py
index 2b6501ff2..57c334377 100644
--- a/bookwyrm/views/helpers.py
+++ b/bookwyrm/views/helpers.py
@@ -32,30 +32,6 @@ def is_bookwyrm_request(request):
return True
-def object_visible_to_user(viewer, obj):
- """ is a user authorized to view an object? """
- if not obj:
- return False
-
- # viewer can't see it if the object's owner blocked them
- if viewer in obj.user.blocks.all():
- return False
-
- # you can see your own posts and any public or unlisted posts
- if viewer == obj.user or obj.privacy in ["public", "unlisted"]:
- return True
-
- # you can see the followers only posts of people you follow
- if obj.privacy == "followers" and obj.user.followers.filter(id=viewer.id).first():
- return True
-
- # you can see dms you are tagged in
- if isinstance(obj, models.Status):
- if obj.privacy == "direct" and obj.mention_users.filter(id=viewer.id).first():
- return True
- return False
-
-
def privacy_filter(viewer, queryset, privacy_levels=None, following_only=False):
""" filter objects that have "user" and "privacy" fields """
privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"]
diff --git a/bookwyrm/views/inbox.py b/bookwyrm/views/inbox.py
index 8c645159e..d1b75997d 100644
--- a/bookwyrm/views/inbox.py
+++ b/bookwyrm/views/inbox.py
@@ -1,9 +1,10 @@
""" incoming activities """
import json
+import re
from urllib.parse import urldefrag
-from django.http import HttpResponse
-from django.http import HttpResponseBadRequest, HttpResponseNotFound
+from django.http import HttpResponse, HttpResponseNotFound
+from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@@ -12,6 +13,7 @@ import requests
from bookwyrm import activitypub, models
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
+from bookwyrm.utils import regex
@method_decorator(csrf_exempt, name="dispatch")
@@ -21,6 +23,10 @@ class Inbox(View):
def post(self, request, username=None):
""" only works as POST request """
+ # first check if this server is on our shitlist
+ if is_blocked_user_agent(request):
+ return HttpResponseForbidden()
+
# make sure the user's inbox even exists
if username:
try:
@@ -34,6 +40,10 @@ class Inbox(View):
except json.decoder.JSONDecodeError:
return HttpResponseBadRequest()
+ # let's be extra sure we didn't block this domain
+ if is_blocked_activity(activity_json):
+ return HttpResponseForbidden()
+
if (
not "object" in activity_json
or not "type" in activity_json
@@ -54,6 +64,25 @@ class Inbox(View):
return HttpResponse()
+def is_blocked_user_agent(request):
+ """ check if a request is from a blocked server based on user agent """
+ # check user agent
+ user_agent = request.headers.get("User-Agent")
+ if not user_agent:
+ return False
+ url = re.search(r"https?://{:s}/?".format(regex.domain), user_agent).group()
+ return models.FederatedServer.is_blocked(url)
+
+
+def is_blocked_activity(activity_json):
+ """ get the sender out of activity json and check if it's blocked """
+ actor = activity_json.get("actor")
+ if not actor:
+ # well I guess it's not even a valid activity so who knows
+ return False
+ return models.FederatedServer.is_blocked(actor)
+
+
@app.task
def activity_task(activity_json):
""" do something with this json we think is legit """
diff --git a/bookwyrm/views/list.py b/bookwyrm/views/list.py
index adf9840d4..3d85280d3 100644
--- a/bookwyrm/views/list.py
+++ b/bookwyrm/views/list.py
@@ -13,7 +13,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
-from .helpers import is_api_request, object_visible_to_user, privacy_filter
+from .helpers import is_api_request, privacy_filter
from .helpers import get_user_from_username
# pylint: disable=no-self-use
@@ -92,7 +92,7 @@ class List(View):
def get(self, request, list_id):
""" display a book list """
book_list = get_object_or_404(models.List, id=list_id)
- if not object_visible_to_user(request.user, book_list):
+ if not book_list.visible_to_user(request.user):
return HttpResponseNotFound()
if is_api_request(request):
@@ -176,7 +176,7 @@ class Curate(View):
def add_book(request):
""" put a book on a list """
book_list = get_object_or_404(models.List, id=request.POST.get("list"))
- if not object_visible_to_user(request.user, book_list):
+ if not book_list.visible_to_user(request.user):
return HttpResponseNotFound()
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
diff --git a/bookwyrm/views/shelf.py b/bookwyrm/views/shelf.py
index 41d1f1358..888999493 100644
--- a/bookwyrm/views/shelf.py
+++ b/bookwyrm/views/shelf.py
@@ -16,7 +16,7 @@ from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_edition, get_user_from_username
-from .helpers import handle_reading_status, privacy_filter, object_visible_to_user
+from .helpers import handle_reading_status, privacy_filter
# pylint: disable= no-self-use
@@ -43,7 +43,7 @@ class Shelf(View):
shelf = user.shelf_set.get(identifier=shelf_identifier)
except models.Shelf.DoesNotExist:
return HttpResponseNotFound()
- if not object_visible_to_user(request.user, shelf):
+ if not shelf.visible_to_user(request.user):
return HttpResponseNotFound()
# this is a constructed "all books" view, with a fake "shelf" obj
else:
diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py
index aba804d8b..d666f064e 100644
--- a/bookwyrm/views/user.py
+++ b/bookwyrm/views/user.py
@@ -17,7 +17,7 @@ from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from .helpers import get_user_from_username, is_api_request
-from .helpers import is_blocked, privacy_filter, object_visible_to_user
+from .helpers import is_blocked, privacy_filter
# pylint: disable= no-self-use
@@ -80,7 +80,7 @@ class User(View):
goal = models.AnnualGoal.objects.filter(
user=user, year=timezone.now().year
).first()
- if not object_visible_to_user(request.user, goal):
+ if goal and not goal.visible_to_user(request.user):
goal = None
data = {
"user": user,