Merge pull request #881 from mouse-reeve/domain-block

Block federation at the domain level
This commit is contained in:
Mouse Reeve 2021-04-12 10:41:02 -07:00 committed by GitHub
commit a1e28d810d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 753 additions and 167 deletions

View file

@ -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,

View file

@ -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"]

View file

@ -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()

View file

@ -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,
),
),
]

View file

@ -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 = []

View file

@ -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 = []

View file

@ -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,
),
),
]

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -6,7 +6,14 @@
{% block content %}
<header class="block column is-offset-one-quarter pl-1">
<h1 class="title">{% block header %}{% endblock %}</h1>
<div class="columns is-mobile">
<div class="column">
<h1 class="title">{% block header %}{% endblock %}</h1>
</div>
<div class="column is-narrow">
{% block edit-button %}{% endblock %}
</div>
</div>
</header>
<div class="block columns">

View file

@ -0,0 +1,58 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% block title %}{% trans "Add server" %}{% endblock %}
{% block header %}
{% trans "Add server" %}
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
{% endblock %}
{% block panel %}
<form method="POST" action="{% url 'settings-add-federated-server' %}">
{% csrf_token %}
<div class="columns">
<div class="column is-half">
<div>
<label class="label" for="id_server_name">{% trans "Instance:" %}</label>
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com">
{% for error in form.server_name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div>
<label class="label" for="id_status">{% trans "Status:" %}</label>
<div class="select">
<select name="status" class="" id="id_status">
<option value="federated" {% if form.status.value == "federated" or not form.status.value %}selected=""{% endif %}>{% trans "Federated" %}</option>
<option value="blocked" {% if form.status.value == "blocked" %}selected{% endif %}>{% trans "Blocked" %}</option>
</select>
</div>
</div>
</div>
<div class="column is-half">
<div>
<label class="label" for="id_application_type">{% trans "Software:" %}</label>
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}">
{% for error in form.application_type.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div>
<label class="label" for="id_application_version">{% trans "Version:" %}</label>
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
{% for error in form.application_version.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
</div>
<p>
<label class="label" for="id_notes">{% trans "Notes:" %}</label>
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
</p>
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</form>
{% endblock %}

View file

@ -4,64 +4,112 @@
{% block header %}
{{ server.server_name }}
{% if server.status == "blocked" %}<span class="icon icon-x has-text-danger is-size-5" title="{% trans 'Blocked' %}"><span class="is-sr-only">{% trans "Blocked" %}</span></span>
{% endif %}
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
{% endblock %}
{% block panel %}
<div class="columns">
<section class="column is-half content">
<h2 class="title is-4">{% trans "Details" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
</section>
<section class="column is-half content">
<h2 class="title is-4">{% trans "Activity" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Users:" %}</dt>
<dd>
{{ users.count }}
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Reports:" %}</dt>
<dd>
{{ reports.count }}
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by us:" %}</dt>
<dd>
{{ followed_by_us.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by them:" %}</dt>
<dd>
{{ followed_by_them.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Blocked by us:" %}</dt>
<dd>
{{ blocked_by_us.count }}
</dd>
</div>
</dl>
</section>
</div>
<section class="block content">
<h2 class="title is-4">{% trans "Details" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
<header class="columns is-mobile">
<div class="column">
<h2 class="title is-4 mb-0">{% trans "Notes" %}</h2>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
<div class="column is-narrow">
{% trans "Edit" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>Federated</dd>
</div>
</dl>
</header>
{% if server.notes %}
<p id="hide-edit-notes">{{ server.notes }}</p>
{% endif %}
<form class="box is-hidden" method="POST" action="{% url 'settings-federated-server' server.id %}" id="edit-notes">
{% csrf_token %}
<p>
<label class="is-sr-only" for="id_notes">Notes:</label>
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ server.notes|default:"" }}</textarea>
</p>
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit-notes" %}
</form>
</section>
<section class="block content">
<h2 class="title is-4">{% trans "Activity" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Users:" %}</dt>
<dd>
{{ users.count }}
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Reports:" %}</dt>
<dd>
{{ reports.count }}
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by us:" %}</dt>
<dd>
{{ followed_by_us.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by them:" %}</dt>
<dd>
{{ followed_by_them.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Blocked by us:" %}</dt>
<dd>
{{ blocked_by_us.count }}
</dd>
</div>
</dl>
<h2 class="title is-4">{% trans "Actions" %}</h2>
{% if server.status != 'blocked' %}
<form class="block" method="post" action="{% url 'settings-federated-server-block' server.id %}">
{% csrf_token %}
<button class="button is-danger">{% trans "Block" %}</button>
<p class="help">{% trans "All users from this instance will be deactivated." %}</p>
</form>
{% else %}
<form class="block" method="post" action="{% url 'settings-federated-server-unblock' server.id %}">
{% csrf_token %}
<button class="button">{% trans "Un-block" %}</button>
<p class="help">{% trans "All users from this instance will be re-activated." %}</p>
</form>
{% endif %}
</section>
{% endblock %}

View file

@ -4,8 +4,15 @@
{% block header %}{% trans "Federated Servers" %}{% endblock %}
{% block panel %}
{% block edit-button %}
<a href="{% url 'settings-add-federated-server' %}">
<span class="icon icon-plus" title="{% trans 'Add server' %}">
<span class="is-sr-only">{% trans "Add server" %}</span>
</span>
</a>
{% endblock %}
{% block panel %}
<table class="table is-striped">
<tr>
{% url 'settings-federation' as url %}

View file

@ -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))

View file

@ -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")

View file

@ -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))

View file

@ -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")

View file

@ -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(

View file

@ -68,6 +68,21 @@ urlpatterns = [
views.FederatedServer.as_view(),
name="settings-federated-server",
),
re_path(
r"^settings/federation/(?P<server>\d+)/block?$",
views.federation.block_server,
name="settings-federated-server-block",
),
re_path(
r"^settings/federation/(?P<server>\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"
),

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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 = {

View file

@ -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"]

View file

@ -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 """

View file

@ -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"))

View file

@ -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:

View file

@ -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,