Fixed #408: Implemented blocking

This commit is contained in:
Andrew Godwin 2023-01-15 13:35:45 -07:00
parent f689110e0b
commit b44be55609
17 changed files with 956 additions and 217 deletions

View File

@ -5,15 +5,17 @@ from django.db import models
from activities.models.timeline_event import TimelineEvent from activities.models.timeline_event import TimelineEvent
from core.ld import canonicalise from core.ld import canonicalise
from stator.models import State, StateField, StateGraph, StatorModel from stator.models import State, StateField, StateGraph, StatorModel
from users.models import FollowStates from users.models import Block, FollowStates
class FanOutStates(StateGraph): class FanOutStates(StateGraph):
new = State(try_interval=600) new = State(try_interval=600)
sent = State(delete_after=86400) sent = State(delete_after=86400)
skipped = State(delete_after=86400)
failed = State(delete_after=86400) failed = State(delete_after=86400)
new.transitions_to(sent) new.transitions_to(sent)
new.transitions_to(skipped)
new.times_out_to(failed, seconds=86400 * 3) new.times_out_to(failed, seconds=86400 * 3)
@classmethod @classmethod
@ -32,6 +34,13 @@ class FanOutStates(StateGraph):
# Handle creating/updating local posts # Handle creating/updating local posts
case ((FanOut.Types.post | FanOut.Types.post_edited), True): case ((FanOut.Types.post | FanOut.Types.post_edited), True):
post = await fan_out.subject_post.afetch_full() post = await fan_out.subject_post.afetch_full()
# If the author of the post is blocked or muted, skip out
if (
await Block.objects.active()
.filter(source=fan_out.identity, target=post.author)
.aexists()
):
return cls.skipped
# Make a timeline event directly # Make a timeline event directly
# If it's a reply, we only add it if we follow at least one # If it's a reply, we only add it if we follow at least one
# of the people mentioned AND the author, or we're mentioned, # of the people mentioned AND the author, or we're mentioned,
@ -126,6 +135,28 @@ class FanOutStates(StateGraph):
# Handle local boosts/likes # Handle local boosts/likes
case (FanOut.Types.interaction, True): case (FanOut.Types.interaction, True):
interaction = await fan_out.subject_post_interaction.afetch_full() interaction = await fan_out.subject_post_interaction.afetch_full()
# If the author of the interaction is blocked or their notifications
# are muted, skip out
if (
await Block.objects.active()
.filter(
models.Q(mute=False) | models.Q(include_notifications=True),
source=fan_out.identity,
target=interaction.identity,
)
.aexists()
):
return cls.skipped
# If blocked/muted the underlying post author, skip out
if (
await Block.objects.active()
.filter(
source=fan_out.identity,
target_id=interaction.post.author_id,
)
.aexists()
):
return cls.skipped
# Make a timeline event directly # Make a timeline event directly
await sync_to_async(TimelineEvent.add_post_interaction)( await sync_to_async(TimelineEvent.add_post_interaction)(
identity=fan_out.identity, identity=fan_out.identity,

View File

@ -712,6 +712,14 @@ class Post(StatorModel):
# If it's a local post, include the author # If it's a local post, include the author
if self.local: if self.local:
targets.add(self.author) targets.add(self.author)
# Fetch the author's full blocks and remove them as targets
blocks = (
self.author.outbound_blocks.active()
.filter(mute=False)
.select_related("target")
)
async for block in blocks:
targets.remove(block.target)
# Now dedupe the targets based on shared inboxes (we only keep one per # Now dedupe the targets based on shared inboxes (we only keep one per
# shared inbox) # shared inbox)
deduped_targets = set() deduped_targets = set()

View File

@ -1,7 +1,7 @@
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from ninja import Field from ninja import Field, Schema
from activities.services import SearchService from activities.services import SearchService
from api import schemas from api import schemas
@ -199,6 +199,51 @@ def account_unfollow(request, id: str):
return service.mastodon_json_relationship(request.identity) return service.mastodon_json_relationship(request.identity)
@api_router.post("/v1/accounts/{id}/block", response=schemas.Relationship)
@identity_required
def account_block(request, id: str):
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.block_from(request.identity)
return service.mastodon_json_relationship(request.identity)
@api_router.post("/v1/accounts/{id}/unblock", response=schemas.Relationship)
@identity_required
def account_unblock(request, id: str):
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.unblock_from(request.identity)
return service.mastodon_json_relationship(request.identity)
class MuteDetailsSchema(Schema):
notifications: bool = True
duration: int = 0
@api_router.post("/v1/accounts/{id}/mute", response=schemas.Relationship)
@identity_required
def account_mute(request, id: str, details: MuteDetailsSchema):
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.mute_from(
request.identity,
duration=details.duration,
include_notifications=details.notifications,
)
return service.mastodon_json_relationship(request.identity)
@api_router.post("/v1/accounts/{id}/unmute", response=schemas.Relationship)
@identity_required
def account_unmute(request, id: str):
identity = get_object_or_404(Identity, pk=id)
service = IdentityService(identity)
service.unmute_from(request.identity)
return service.mastodon_json_relationship(request.identity)
@api_router.get("/v1/accounts/{id}/following", response=list[schemas.Account]) @api_router.get("/v1/accounts/{id}/following", response=list[schemas.Account])
def account_following( def account_following(
request: HttpRequest, request: HttpRequest,

View File

@ -20,7 +20,7 @@ def instance_info(request):
"urls": {}, "urls": {},
"stats": { "stats": {
"user_count": Identity.objects.filter(local=True).count(), "user_count": Identity.objects.filter(local=True).count(),
"status_count": Post.objects.filter(local=True).count(), "status_count": Post.objects.filter(local=True).not_hidden().count(),
"domain_count": Domain.objects.count(), "domain_count": Domain.objects.count(),
}, },
"thumbnail": Config.system.site_banner, "thumbnail": Config.system.site_banner,

View File

@ -1078,6 +1078,11 @@ button.htmx-request::before,
animation-timing-function: var(--fa-animation-timing, linear); animation-timing-function: var(--fa-animation-timing, linear);
} }
button i:first-child,
.button i:first-child {
margin-right: 3px;
}
.right-column button, .right-column button,
.right-column .button { .right-column .button {
padding: 2px 6px; padding: 2px 6px;

View File

@ -1,19 +1,27 @@
<div class="inline follow-profile {% if reverse_follow %}has-reverse{% endif %}"> <div class="inline follow-profile {% if inbound_follow %}has-reverse{% endif %}">
<div class="actions" role="menubar"> <div class="actions" role="menubar">
{% if request.identity == identity %} {% if request.identity == identity %}
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile"> <a href="{% url "settings_profile" %}" class="button" title="Edit Profile">
<i class="fa-solid fa-user-edit"></i> <i class="fa-solid fa-user-edit"></i>
</a> </a>
{% else %} {% elif not inbound_block %}
{% if reverse_follow %} {% if inbound_follow or outbound_mute %}
<span class="reverse-follow">Follows You</span> <span class="reverse-follow">
{% if inbound_follow %}Follows You{% endif %}{% if inbound_follow and outbound_mute %},{% endif %}
{% if outbound_mute %}Muted{% endif %}
</span>
{% endif %} {% endif %}
<form action="{{ identity.urls.action }}" method="POST" class="inline-menu"> <form action="{{ identity.urls.action }}" method="POST" class="inline-menu">
{% csrf_token %} {% csrf_token %}
{% if follow %} {% if outbound_block %}
<input type="hidden" name="action" value="unblock">
<button class="destructive" title="Unblock"><i class="fa-solid fa-ban"></i>
Unblock
</button>
{% elif outbound_follow %}
<input type="hidden" name="action" value="unfollow"> <input type="hidden" name="action" value="unfollow">
<button class="destructive" title="Unfollow"><i class="fa-solid fa-user-minus"></i> <button class="destructive" title="Unfollow"><i class="fa-solid fa-user-minus"></i>
{% if follow.pending %}Follow Pending{% else %}Unfollow{% endif %} {% if outbound_follow.pending %}Follow Pending{% else %}Unfollow{% endif %}
</button> </button>
{% else %} {% else %}
<input type="hidden" name="action" value="follow"> <input type="hidden" name="action" value="follow">
@ -21,16 +29,14 @@
{% endif %} {% endif %}
</form> </form>
{% endif %} {% endif %}
{% if request.user.admin %}
<a title="Menu" class="menu button" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" aria-haspopup="menu" tabindex="0"> <a title="Menu" class="menu button" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i> <i class="fa-solid fa-bars"></i>
</a> </a>
<menu> <menu>
{% if follow %} {% if outbound_follow %}
<form action="{{ identity.urls.action }}" method="POST" class="inline"> <form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %} {% csrf_token %}
{% if follow.boosts %} {% if outbound_follow.boosts %}
<input type="hidden" name="action" value="hide_boosts"> <input type="hidden" name="action" value="hide_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button> <button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button>
{% else %} {% else %}
@ -39,6 +45,26 @@
{% endif %} {% endif %}
</form> </form>
{% endif %} {% endif %}
{% if not outbound_block %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="block">
<button role="menuitem"><i class="fa-solid fa-ban"></i> Block user</button>
</form>
{% if outbound_mute %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="unmute">
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Unmute user</button>
</form>
{% else %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="mute">
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Mute user</button>
</form>
{% endif %}
{% endif %}
{% if request.user.admin %} {% if request.user.admin %}
<a href="{{ identity.urls.admin_edit }}" role="menuitem"> <a href="{{ identity.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-user-gear"></i> View in Admin <i class="fa-solid fa-user-gear"></i> View in Admin
@ -48,6 +74,5 @@
</a> </a>
{% endif %} {% endif %}
</menu> </menu>
{% endif %}
</div> </div>
</div> </div>

View File

@ -40,6 +40,12 @@
</small> </small>
</h1> </h1>
{% if inbound_block %}
<p class="system-note">
This user has blocked you.
</p>
{% else %}
{% if identity.summary %} {% if identity.summary %}
<div class="bio"> <div class="bio">
{{ identity.safe_summary }} {{ identity.safe_summary }}
@ -104,4 +110,5 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% endif %}
{% endblock %} {% endblock %}

View File

@ -2,7 +2,7 @@ import pytest
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from activities.models import Post from activities.models import Post
from users.models import Domain, Follow, Identity from users.models import Block, Domain, Follow, Identity
@pytest.mark.django_db @pytest.mark.django_db
@ -158,3 +158,26 @@ def test_post_followers(identity, other_identity, remote_identity):
post.save() post.save()
targets = async_to_sync(post.aget_targets)() targets = async_to_sync(post.aget_targets)()
assert targets == {identity} assert targets == {identity}
@pytest.mark.django_db
def test_post_blocked(identity, other_identity, remote_identity):
"""
Blocked users should never get a copy of a post even if they're mentioned.
"""
# Block the two other identities, one with mute only
Block.create_local_mute(identity, other_identity)
Block.create_local_block(identity, remote_identity)
# Make a post
post = Post.objects.create(
content="<p>Hello @test and @other</p>",
author=identity,
local=True,
)
post.mentions.add(remote_identity)
post.mentions.add(other_identity)
# The muted block should be in targets, the full block should not
targets = async_to_sync(post.aget_targets)()
assert targets == {identity, other_identity}

View File

@ -0,0 +1,139 @@
import pytest
from activities.models import Post, TimelineEvent
from activities.services import PostService
from users.models import Block, Identity, InboxMessage
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
@pytest.mark.parametrize("blocked", ["full", "mute", "no"])
def test_mentioned(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
blocked: bool,
):
"""
Ensures that a new or incoming post that mentions a local identity results in a
mentioned timeline event, unless the author is blocked.
"""
if local:
Post.create_local(author=other_identity, content=f"Hello @{identity.handle}!")
else:
# Create an inbound new post message
message = {
"id": "test",
"type": "Create",
"actor": remote_identity.actor_uri,
"object": {
"id": "https://remote.test/test-post",
"type": "Note",
"published": "2022-11-13T23:20:16Z",
"attributedTo": remote_identity.actor_uri,
"content": f"Hello @{identity.handle}!",
"tag": {
"type": "Mention",
"href": identity.actor_uri,
"name": f"@{identity.handle}",
},
},
}
InboxMessage.objects.create(message=message)
# Implement any blocks
author = other_identity if local else remote_identity
if blocked == "full":
Block.create_local_block(identity, author)
elif blocked == "mute":
Block.create_local_mute(identity, author)
# Run stator twice - to make fanouts and then process them
stator.run_single_cycle_sync()
stator.run_single_cycle_sync()
if blocked in ["full", "mute"]:
# Verify we were not mentioned
assert not TimelineEvent.objects.filter(
type=TimelineEvent.Types.mentioned, identity=identity
).exists()
else:
# Verify we got mentioned
event = TimelineEvent.objects.filter(
type=TimelineEvent.Types.mentioned, identity=identity
).first()
assert event
assert event.subject_identity == author
assert "Hello " in event.subject_post.content
@pytest.mark.django_db
@pytest.mark.parametrize("local", [True, False])
@pytest.mark.parametrize("type", ["like", "boost"])
@pytest.mark.parametrize("blocked", ["full", "mute", "mute_with_notifications", "no"])
def test_interaction_local_post(
identity: Identity,
other_identity: Identity,
remote_identity: Identity,
stator,
local: bool,
type: str,
blocked: bool,
):
"""
Ensures that a like of a local Post notifies its author
"""
post = Post.create_local(author=identity, content="I love birds!")
if local:
if type == "boost":
PostService(post).boost_as(other_identity)
else:
PostService(post).like_as(other_identity)
else:
if type == "boost":
message = {
"id": "test",
"type": "Announce",
"to": "as:Public",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
}
else:
message = {
"id": "test",
"type": "Like",
"actor": remote_identity.actor_uri,
"object": post.object_uri,
}
InboxMessage.objects.create(message=message)
# Implement any blocks
interactor = other_identity if local else remote_identity
if blocked == "full":
Block.create_local_block(identity, interactor)
elif blocked == "mute":
Block.create_local_mute(identity, interactor)
elif blocked == "mute_with_notifications":
Block.create_local_mute(identity, interactor, include_notifications=True)
# Run stator twice - to make fanouts and then process them
stator.run_single_cycle_sync()
stator.run_single_cycle_sync()
timeline_event_type = (
TimelineEvent.Types.boosted if type == "boost" else TimelineEvent.Types.liked
)
if blocked in ["full", "mute_with_notifications"]:
# Verify we did not get an event
assert not TimelineEvent.objects.filter(
type=timeline_event_type, identity=identity
).exists()
else:
# Verify we got an event
event = TimelineEvent.objects.filter(
type=timeline_event_type, identity=identity
).first()
assert event
assert event.subject_identity == interactor

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from activities.admin import IdentityLocalFilter from activities.admin import IdentityLocalFilter
from users.models import ( from users.models import (
Announcement, Announcement,
Block,
Domain, Domain,
Follow, Follow,
Identity, Identity,
@ -158,6 +159,16 @@ class FollowAdmin(admin.ModelAdmin):
return False return False
@admin.register(Block)
class BlockAdmin(admin.ModelAdmin):
list_display = ["id", "source", "target", "mute", "state"]
list_filter = [LocalSourceFilter, LocalTargetFilter, "state"]
raw_id_fields = ["source", "target"]
def has_add_permission(self, request, obj=None):
return False
@admin.register(PasswordReset) @admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin): class PasswordResetAdmin(admin.ModelAdmin):
list_display = ["id", "user", "created"] list_display = ["id", "user", "created"]

View File

@ -0,0 +1,70 @@
# Generated by Django 4.1.4 on 2023-01-15 20:04
import django.utils.timezone
from django.db import migrations, models
import stator.models
import users.models.block
class Migration(migrations.Migration):
dependencies = [
("users", "0011_announcement"),
]
operations = [
migrations.AddField(
model_name="block",
name="include_notifications",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="block",
name="state",
field=stator.models.StateField(
choices=[
("new", "new"),
("sent", "sent"),
("awaiting_expiry", "awaiting_expiry"),
("undone", "undone"),
("undone_sent", "undone_sent"),
],
default="new",
graph=users.models.block.BlockStates,
max_length=100,
),
),
migrations.AddField(
model_name="block",
name="state_attempted",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="block",
name="state_changed",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="block",
name="state_locked_until",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="block",
name="state_ready",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="block",
name="uri",
field=models.CharField(blank=True, max_length=500, null=True),
),
migrations.AlterUniqueTogether(
name="block",
unique_together={("source", "target", "mute")},
),
]

View File

@ -1,5 +1,5 @@
from .announcement import Announcement # noqa from .announcement import Announcement # noqa
from .block import Block # noqa from .block import Block, BlockStates # noqa
from .domain import Domain # noqa from .domain import Domain # noqa
from .follow import Follow, FollowStates # noqa from .follow import Follow, FollowStates # noqa
from .identity import Identity, IdentityStates # noqa from .identity import Identity, IdentityStates # noqa

View File

@ -1,11 +1,111 @@
from django.db import models import datetime
from typing import Optional
import httpx
from django.db import models, transaction
from django.utils import timezone
from core.ld import canonicalise, get_str_or_id
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.identity import Identity
class Block(models.Model): class BlockStates(StateGraph):
new = State(try_interval=600)
sent = State(externally_progressed=True)
awaiting_expiry = State(try_interval=60 * 60, attempt_immediately=False)
undone = State(try_interval=60 * 60, delete_after=86400 * 7)
undone_sent = State(delete_after=86400)
new.transitions_to(sent)
new.transitions_to(awaiting_expiry)
sent.transitions_to(undone)
awaiting_expiry.transitions_to(undone)
# We don't really care if the other end accepts our block
new.times_out_to(sent, seconds=86400 * 7)
undone.transitions_to(undone_sent)
@classmethod
def group_active(cls):
return [cls.new, cls.sent, cls.awaiting_expiry]
@classmethod
async def handle_new(cls, instance: "Block"):
"""
Block that are new need us to deliver the Block object
to the target server.
"""
# Mutes don't send but might need expiry
if instance.mute:
return cls.awaiting_expiry
# Fetch more info
block = await instance.afetch_full()
# Remote blocks should not be here, local blocks just work
if not block.source.local or block.target.local:
return cls.sent
# Don't try if the other identity didn't fetch yet
if not block.target.inbox_uri:
return
# Sign it and send it
try:
await block.source.signed_request(
method="post",
uri=block.target.inbox_uri,
body=canonicalise(block.to_ap()),
)
except httpx.RequestError:
return
return cls.sent
@classmethod
async def handle_awaiting_expiry(cls, instance: "Block"):
"""
Checks to see if there is an expiry we should undo
"""
if instance.expires and instance.expires <= timezone.now():
return cls.undone
@classmethod
async def handle_undone(cls, instance: "Block"):
"""
Delivers the Undo object to the target server
"""
block = await instance.afetch_full()
# Remote blocks should not be here, mutes don't send, local blocks just work
if not block.source.local or block.target.local or instance.mute:
return cls.undone_sent
try:
await block.source.signed_request(
method="post",
uri=block.target.inbox_uri,
body=canonicalise(block.to_undo_ap()),
)
except httpx.RequestError:
return
return cls.undone_sent
class BlockQuerySet(models.QuerySet):
def active(self):
query = self.filter(state__in=BlockStates.group_active())
return query
class BlockManager(models.Manager):
def get_queryset(self):
return BlockQuerySet(self.model, using=self._db)
def active(self):
return self.get_queryset().active()
class Block(StatorModel):
""" """
When one user (the source) mutes or blocks another (the target) When one user (the source) mutes or blocks another (the target)
""" """
state = StateField(BlockStates)
source = models.ForeignKey( source = models.ForeignKey(
"users.Identity", "users.Identity",
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -18,13 +118,209 @@ class Block(models.Model):
related_name="inbound_blocks", related_name="inbound_blocks",
) )
uri = models.CharField(blank=True, null=True, max_length=500)
# If it is a mute, we will stop delivering any activities from target to # If it is a mute, we will stop delivering any activities from target to
# source, but we will still deliver activities from source to target. # source, but we will still deliver activities from source to target.
# A full block (non-mute) stops activities both ways. # A full block (mute=False) stops activities both ways.
mute = models.BooleanField() mute = models.BooleanField()
include_notifications = models.BooleanField(default=False)
expires = models.DateTimeField(blank=True, null=True) expires = models.DateTimeField(blank=True, null=True)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
objects = BlockManager()
class Meta:
unique_together = [("source", "target", "mute")]
def __str__(self):
return f"#{self.id}: {self.source} blocks {self.target}"
### Alternate fetchers/constructors ###
@classmethod
def maybe_get(
cls, source, target, mute=False, require_active=False
) -> Optional["Block"]:
"""
Returns a Block if it exists between source and target
"""
try:
if require_active:
return cls.objects.active().get(source=source, target=target, mute=mute)
else:
return cls.objects.get(source=source, target=target, mute=mute)
except cls.DoesNotExist:
return None
@classmethod
def create_local_block(cls, source, target) -> "Block":
"""
Creates or updates a full Block from a local Identity to the target
(which can be local or remote).
"""
if not source.local:
raise ValueError("You cannot block from a remote Identity")
block = cls.maybe_get(source=source, target=target, mute=False)
if block is not None:
if not block.active:
block.state = BlockStates.new # type:ignore
block.save()
else:
with transaction.atomic():
block = cls.objects.create(
source=source,
target=target,
mute=False,
)
block.uri = source.actor_uri + f"block/{block.pk}/"
block.save()
return block
@classmethod
def create_local_mute(
cls,
source,
target,
duration=None,
include_notifications=False,
) -> "Block":
"""
Creates or updates a muting Block from a local Identity to the target
(which can be local or remote).
"""
if not source.local:
raise ValueError("You cannot mute from a remote Identity")
block = cls.maybe_get(source=source, target=target, mute=True)
if block is not None:
if not block.active:
block.state = BlockStates.new # type:ignore
if duration:
block.expires = timezone.now() + datetime.timedelta(seconds=duration)
block.include_notifications = include_notifications
block.save()
else:
with transaction.atomic():
block = cls.objects.create(
source=source,
target=target,
mute=True,
include_notifications=include_notifications,
expires=(
timezone.now() + datetime.timedelta(seconds=duration)
if duration
else None
),
)
block.uri = source.actor_uri + f"block/{block.pk}/"
block.save()
return block
### Properties ###
@property
def active(self):
return self.state in BlockStates.group_active()
### Async helpers ###
async def afetch_full(self):
"""
Returns a version of the object with all relations pre-loaded
"""
return await Block.objects.select_related(
"source", "source__domain", "target"
).aget(pk=self.pk)
### ActivityPub (outbound) ###
def to_ap(self):
"""
Returns the AP JSON for this object
"""
if self.mute:
raise ValueError("Cannot send mutes over ActivityPub")
return {
"type": "Block",
"id": self.uri,
"actor": self.source.actor_uri,
"object": self.target.actor_uri,
}
def to_undo_ap(self):
"""
Returns the AP JSON for this objects' undo.
"""
return {
"type": "Undo",
"id": self.uri + "#undo",
"actor": self.source.actor_uri,
"object": self.to_ap(),
}
### ActivityPub (inbound) ###
@classmethod
def by_ap(cls, data: str | dict, create=False) -> "Block":
"""
Retrieves a Block instance by its ActivityPub JSON object or its URI.
Optionally creates one if it's not present.
Raises KeyError if it's not found and create is False.
"""
# If it's a string, do the reference resolve
if isinstance(data, str):
bits = data.strip("/").split("/")
if bits[-2] != "block":
raise ValueError(f"Unknown Block object URI: {data}")
return Block.objects.get(pk=bits[-1])
# Otherwise, do the object resolve
else:
# Resolve source and target and see if a Block exists
source = Identity.by_actor_uri(data["actor"], create=create)
target = Identity.by_actor_uri(get_str_or_id(data["object"]))
block = cls.maybe_get(source=source, target=target, mute=False)
# If it doesn't exist, create one in the sent state
if block is None:
if create:
return cls.objects.create(
source=source,
target=target,
uri=data["id"],
mute=False,
state=BlockStates.sent,
)
else:
raise cls.DoesNotExist(
f"No block with source {source} and target {target}", data
)
else:
return block
@classmethod
def handle_ap(cls, data):
"""
Handles an incoming Block notification
"""
with transaction.atomic():
cls.by_ap(data, create=True)
@classmethod
def handle_undo_ap(cls, data):
"""
Handles an incoming Block Undo
"""
# Resolve source and target and see if a Follow exists (it hopefully does)
try:
block = cls.by_ap(data["object"])
except KeyError:
raise ValueError("No Block locally for incoming Undo", data)
# Check the block's source is the actor
if data["actor"] != block.source.actor_uri:
raise ValueError("Undo actor does not match its Block object", data)
# Delete the follow
block.delete()

View File

@ -97,6 +97,20 @@ class FollowStates(StateGraph):
return cls.undone_remotely return cls.undone_remotely
class FollowQuerySet(models.QuerySet):
def active(self):
query = self.filter(state__in=FollowStates.group_active())
return query
class FollowManager(models.Manager):
def get_queryset(self):
return FollowQuerySet(self.model, using=self._db)
def active(self):
return self.get_queryset().active()
class Follow(StatorModel): class Follow(StatorModel):
""" """
When one user (the source) follows other (the target) When one user (the source) follows other (the target)
@ -127,6 +141,8 @@ class Follow(StatorModel):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
objects = FollowManager()
class Meta: class Meta:
unique_together = [("source", "target")] unique_together = [("source", "target")]
@ -136,11 +152,14 @@ class Follow(StatorModel):
### Alternate fetchers/constructors ### ### Alternate fetchers/constructors ###
@classmethod @classmethod
def maybe_get(cls, source, target) -> Optional["Follow"]: def maybe_get(cls, source, target, require_active=False) -> Optional["Follow"]:
""" """
Returns a follow if it exists between source and target Returns a follow if it exists between source and target
""" """
try: try:
if require_active:
return Follow.objects.active().get(source=source, target=target)
else:
return Follow.objects.get(source=source, target=target) return Follow.objects.get(source=source, target=target)
except Follow.DoesNotExist: except Follow.DoesNotExist:
return None return None
@ -157,17 +176,28 @@ class Follow(StatorModel):
raise ValueError("You cannot initiate follows from a remote Identity") raise ValueError("You cannot initiate follows from a remote Identity")
try: try:
follow = Follow.objects.get(source=source, target=target) follow = Follow.objects.get(source=source, target=target)
if follow.boosts != boosts: if not follow.active:
follow.state = (
FollowStates.accepted if target.local else FollowStates.unrequested
)
follow.boosts = boosts follow.boosts = boosts
follow.save() follow.save()
except Follow.DoesNotExist: except Follow.DoesNotExist:
with transaction.atomic():
follow = Follow.objects.create( follow = Follow.objects.create(
source=source, target=target, boosts=boosts, uri="" source=source,
target=target,
boosts=boosts,
uri="",
state=(
FollowStates.accepted
if target.local
else FollowStates.unrequested
),
) )
follow.uri = source.actor_uri + f"follow/{follow.pk}/" follow.uri = source.actor_uri + f"follow/{follow.pk}/"
# TODO: Local follow approvals # TODO: Local follow approvals
if target.local: if target.local:
follow.state = FollowStates.accepted
TimelineEvent.add_follow(follow.target, follow.source) TimelineEvent.add_follow(follow.target, follow.source)
follow.save() follow.save()
return follow return follow
@ -182,12 +212,16 @@ class Follow(StatorModel):
"source", "source__domain", "target" "source", "source__domain", "target"
).aget(pk=self.pk) ).aget(pk=self.pk)
### Helper properties ### ### Properties ###
@property @property
def pending(self): def pending(self):
return self.state in [FollowStates.unrequested, FollowStates.local_requested] return self.state in [FollowStates.unrequested, FollowStates.local_requested]
@property
def active(self):
return self.state in FollowStates.group_active()
### ActivityPub (outbound) ### ### ActivityPub (outbound) ###
def to_ap(self): def to_ap(self):
@ -226,13 +260,21 @@ class Follow(StatorModel):
### ActivityPub (inbound) ### ### ActivityPub (inbound) ###
@classmethod @classmethod
def by_ap(cls, data, create=False) -> "Follow": def by_ap(cls, data: str | dict, create=False) -> "Follow":
""" """
Retrieves a Follow instance by its ActivityPub JSON object. Retrieves a Follow instance by its ActivityPub JSON object or its URI.
Optionally creates one if it's not present. Optionally creates one if it's not present.
Raises KeyError if it's not found and create is False. Raises DoesNotExist if it's not found and create is False.
""" """
# If it's a string, do the reference resolve
if isinstance(data, str):
bits = data.strip("/").split("/")
if bits[-2] != "follow":
raise ValueError(f"Unknown Follow object URI: {data}")
return Follow.objects.get(pk=bits[-1])
# Otherwise, do object resolve
else:
# Resolve source and target and see if a Follow exists # Resolve source and target and see if a Follow exists
source = Identity.by_actor_uri(data["actor"], create=create) source = Identity.by_actor_uri(data["actor"], create=create)
target = Identity.by_actor_uri(get_str_or_id(data["object"])) target = Identity.by_actor_uri(get_str_or_id(data["object"]))
@ -247,7 +289,7 @@ class Follow(StatorModel):
state=FollowStates.remote_requested, state=FollowStates.remote_requested,
) )
else: else:
raise KeyError( raise cls.DoesNotExist(
f"No follow with source {source} and target {target}", data f"No follow with source {source} and target {target}", data
) )
else: else:
@ -272,14 +314,14 @@ class Follow(StatorModel):
""" """
Handles an incoming Follow Accept for one of our follows Handles an incoming Follow Accept for one of our follows
""" """
# Ensure the Accept actor is the Follow's object
if data["actor"] != data["object"]["object"]:
raise ValueError("Accept actor does not match its Follow object", data)
# Resolve source and target and see if a Follow exists (it really should) # Resolve source and target and see if a Follow exists (it really should)
try: try:
follow = cls.by_ap(data["object"]) follow = cls.by_ap(data["object"])
except KeyError: except cls.DoesNotExist:
raise ValueError("No Follow locally for incoming Accept", data) raise ValueError("No Follow locally for incoming Accept", data)
# Ensure the Accept actor is the Follow's target
if data["actor"] != follow.target.actor_uri:
raise ValueError("Accept actor does not match its Follow object", data)
# If the follow was waiting to be accepted, transition it # If the follow was waiting to be accepted, transition it
if follow and follow.state in [ if follow and follow.state in [
FollowStates.unrequested, FollowStates.unrequested,
@ -288,55 +330,33 @@ class Follow(StatorModel):
follow.transition_perform(FollowStates.accepted) follow.transition_perform(FollowStates.accepted)
@classmethod @classmethod
def handle_accept_ref_ap(cls, data): def handle_reject_ap(cls, data):
""" """
Handles an incoming Follow Accept for one of our follows where there is Handles an incoming Follow Reject for one of our follows
only an object URI reference.
""" """
# Ensure the object ref is in a format we expect # Resolve source and target and see if a Follow exists (it really should)
bits = data["object"].strip("/").split("/") try:
if bits[-2] != "follow": follow = cls.by_ap(data["object"])
raise ValueError(f"Unknown Follow object URI in Accept: {data['object']}") except cls.DoesNotExist:
# Retrieve the object by PK raise ValueError("No Follow locally for incoming Reject", data)
follow = cls.objects.get(pk=bits[-1]) # Ensure the Accept actor is the Follow's target
# Ensure it's from the right actor
if data["actor"] != follow.target.actor_uri: if data["actor"] != follow.target.actor_uri:
raise ValueError("Accept actor does not match its Follow object", data) raise ValueError("Reject actor does not match its Follow object", data)
# If the follow was waiting to be accepted, transition it # Mark the follow rejected
if follow.state in [ follow.transition_perform(FollowStates.rejected)
FollowStates.unrequested,
FollowStates.local_requested,
]:
follow.transition_perform(FollowStates.accepted)
@classmethod @classmethod
def handle_undo_ap(cls, data): def handle_undo_ap(cls, data):
""" """
Handles an incoming Follow Undo for one of our follows Handles an incoming Follow Undo for one of our follows
""" """
# Ensure the Undo actor is the Follow's actor
if data["actor"] != data["object"]["actor"]:
raise ValueError("Undo actor does not match its Follow object", data)
# Resolve source and target and see if a Follow exists (it hopefully does) # Resolve source and target and see if a Follow exists (it hopefully does)
try: try:
follow = cls.by_ap(data["object"]) follow = cls.by_ap(data["object"])
except KeyError: except cls.DoesNotExist:
raise ValueError("No Follow locally for incoming Undo", data) raise ValueError("No Follow locally for incoming Undo", data)
# Ensure the Undo actor is the Follow's source
if data["actor"] != follow.source.actor_uri:
raise ValueError("Accept actor does not match its Follow object", data)
# Delete the follow # Delete the follow
follow.delete() follow.delete()
@classmethod
def handle_reject_ap(cls, data):
"""
Handles an incoming Follow Reject for one of our follows
"""
# Ensure the Accept actor is the Follow's object
if data["actor"] != data["object"]["object"]:
raise ValueError("Accept actor does not match its Follow object", data)
# Resolve source and target and see if a Follow exists (it really should)
try:
follow = cls.by_ap(data["object"])
except KeyError:
raise ValueError("No Follow locally for incoming Reject", data)
# Mark the follow rejected
follow.transition_perform(FollowStates.rejected)

View File

@ -15,11 +15,13 @@ class InboxMessageStates(StateGraph):
@classmethod @classmethod
async def handle_received(cls, instance: "InboxMessage"): async def handle_received(cls, instance: "InboxMessage"):
from activities.models import Post, PostInteraction from activities.models import Post, PostInteraction
from users.models import Follow, Identity, Report from users.models import Block, Follow, Identity, Report
match instance.message_type: match instance.message_type:
case "follow": case "follow":
await sync_to_async(Follow.handle_request_ap)(instance.message) await sync_to_async(Follow.handle_request_ap)(instance.message)
case "block":
await sync_to_async(Block.handle_ap)(instance.message)
case "announce": case "announce":
await sync_to_async(PostInteraction.handle_ap)(instance.message) await sync_to_async(PostInteraction.handle_ap)(instance.message)
case "like": case "like":
@ -65,9 +67,8 @@ class InboxMessageStates(StateGraph):
case "follow": case "follow":
await sync_to_async(Follow.handle_accept_ap)(instance.message) await sync_to_async(Follow.handle_accept_ap)(instance.message)
case None: case None:
await sync_to_async(Follow.handle_accept_ref_ap)( # It's a string object, but these will only be for Follows
instance.message await sync_to_async(Follow.handle_accept_ap)(instance.message)
)
case unknown: case unknown:
raise ValueError( raise ValueError(
f"Cannot handle activity of type accept.{unknown}" f"Cannot handle activity of type accept.{unknown}"
@ -76,6 +77,9 @@ class InboxMessageStates(StateGraph):
match instance.message_object_type: match instance.message_object_type:
case "follow": case "follow":
await sync_to_async(Follow.handle_reject_ap)(instance.message) await sync_to_async(Follow.handle_reject_ap)(instance.message)
case None:
# It's a string object, but these will only be for Follows
await sync_to_async(Follow.handle_reject_ap)(instance.message)
case unknown: case unknown:
raise ValueError( raise ValueError(
f"Cannot handle activity of type reject.{unknown}" f"Cannot handle activity of type reject.{unknown}"
@ -84,6 +88,8 @@ class InboxMessageStates(StateGraph):
match instance.message_object_type: match instance.message_object_type:
case "follow": case "follow":
await sync_to_async(Follow.handle_undo_ap)(instance.message) await sync_to_async(Follow.handle_undo_ap)(instance.message)
case "block":
await sync_to_async(Block.handle_undo_ap)(instance.message)
case "like": case "like":
await sync_to_async(PostInteraction.handle_undo_ap)( await sync_to_async(PostInteraction.handle_undo_ap)(
instance.message instance.message

View File

@ -1,10 +1,8 @@
from typing import cast
from django.db import models from django.db import models
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
from core.html import strip_html from core.html import strip_html
from users.models import Follow, FollowStates, Identity from users.models import Block, BlockStates, Follow, FollowStates, Identity
class IdentityService: class IdentityService:
@ -36,16 +34,7 @@ class IdentityService:
Follows a user (or does nothing if already followed). Follows a user (or does nothing if already followed).
Returns the follow. Returns the follow.
""" """
existing_follow = Follow.maybe_get(from_identity, self.identity)
if not existing_follow:
return Follow.create_local(from_identity, self.identity, boosts=boosts) return Follow.create_local(from_identity, self.identity, boosts=boosts)
elif existing_follow.state not in FollowStates.group_active():
existing_follow.transition_perform(FollowStates.unrequested)
if existing_follow.boosts != boosts:
existing_follow.boosts = boosts
existing_follow.save()
return cast(Follow, existing_follow)
def unfollow_from(self, from_identity: Identity): def unfollow_from(self, from_identity: Identity):
""" """
@ -55,34 +44,95 @@ class IdentityService:
if existing_follow: if existing_follow:
existing_follow.transition_perform(FollowStates.undone) existing_follow.transition_perform(FollowStates.undone)
def block_from(self, from_identity: Identity) -> Block:
"""
Blocks a user.
"""
self.unfollow_from(from_identity)
return Block.create_local_block(from_identity, self.identity)
def unblock_from(self, from_identity: Identity):
"""
Unlocks a user
"""
existing_block = Block.maybe_get(from_identity, self.identity, mute=False)
if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone)
def mute_from(
self,
from_identity: Identity,
duration: int = 0,
include_notifications: bool = False,
) -> Block:
"""
Mutes a user.
"""
return Block.create_local_mute(
from_identity,
self.identity,
duration=duration or None,
include_notifications=include_notifications,
)
def unmute_from(self, from_identity: Identity):
"""
Unmutes a user
"""
existing_block = Block.maybe_get(from_identity, self.identity, mute=True)
if existing_block and existing_block.active:
existing_block.transition_perform(BlockStates.undone)
def relationships(self, from_identity: Identity):
"""
Returns a dict of any active relationships from the given identity.
"""
return {
"outbound_follow": Follow.maybe_get(
from_identity, self.identity, require_active=True
),
"inbound_follow": Follow.maybe_get(
self.identity, from_identity, require_active=True
),
"outbound_block": Block.maybe_get(
from_identity, self.identity, mute=False, require_active=True
),
"inbound_block": Block.maybe_get(
self.identity, from_identity, mute=False, require_active=True
),
"outbound_mute": Block.maybe_get(
from_identity, self.identity, mute=True, require_active=True
),
}
def mastodon_json_relationship(self, from_identity: Identity): def mastodon_json_relationship(self, from_identity: Identity):
""" """
Returns a Relationship object for the from_identity's relationship Returns a Relationship object for the from_identity's relationship
with this identity. with this identity.
""" """
relationships = self.relationships(from_identity)
follow = self.identity.inbound_follows.filter(
source=from_identity,
state__in=FollowStates.group_active(),
).first()
return { return {
"id": self.identity.pk, "id": self.identity.pk,
"following": follow is not None, "following": relationships["outbound_follow"] is not None,
"followed_by": self.identity.outbound_follows.filter( "followed_by": relationships["inbound_follow"] is not None,
target=from_identity, "showing_reblogs": (
state__in=FollowStates.group_active(), relationships["outbound_follow"]
).exists(), and relationships["outbound_follow"].boosts
"showing_reblogs": follow and follow.boosts or False, or False
),
"notifying": False, "notifying": False,
"blocking": False, "blocking": relationships["outbound_block"] is not None,
"blocked_by": False, "blocked_by": relationships["inbound_block"] is not None,
"muting": False, "muting": relationships["outbound_mute"] is not None,
"muting_notifications": False, "muting_notifications": False,
"requested": False, "requested": False,
"domain_blocking": False, "domain_blocking": False,
"endorsed": False, "endorsed": False,
"note": (follow and follow.note) or "", "note": (
relationships["outbound_follow"]
and relationships["outbound_follow"].note
or ""
),
} }
def set_summary(self, summary: str): def set_summary(self, summary: str):

View File

@ -18,7 +18,7 @@ from core.decorators import cache_page, cache_page_by_ap_json
from core.ld import canonicalise from core.ld import canonicalise
from core.models import Config from core.models import Config
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Domain, Follow, FollowStates, Identity, IdentityStates from users.models import Domain, FollowStates, Identity, IdentityStates
from users.services import IdentityService from users.services import IdentityService
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -70,8 +70,6 @@ class ViewIdentity(ListView):
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["identity"] = self.identity context["identity"] = self.identity
context["follow"] = None
context["reverse_follow"] = None
context["interactions"] = PostInteraction.get_post_interactions( context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], context["page_obj"],
self.request.identity, self.request.identity,
@ -85,12 +83,9 @@ class ViewIdentity(ListView):
state__in=FollowStates.group_active() state__in=FollowStates.group_active()
).count() ).count()
if self.request.identity: if self.request.identity:
follow = Follow.maybe_get(self.request.identity, self.identity) context.update(
if follow and follow.state in FollowStates.group_active(): IdentityService(self.identity).relationships(self.request.identity)
context["follow"] = follow )
reverse_follow = Follow.maybe_get(self.identity, self.request.identity)
if reverse_follow and reverse_follow.state in FollowStates.group_active():
context["reverse_follow"] = reverse_follow
return context return context
@ -257,6 +252,14 @@ class ActionIdentity(View):
IdentityService(identity).follow_from(self.request.identity) IdentityService(identity).follow_from(self.request.identity)
elif action == "unfollow": elif action == "unfollow":
IdentityService(identity).unfollow_from(self.request.identity) IdentityService(identity).unfollow_from(self.request.identity)
elif action == "block":
IdentityService(identity).block_from(self.request.identity)
elif action == "unblock":
IdentityService(identity).unblock_from(self.request.identity)
elif action == "mute":
IdentityService(identity).mute_from(self.request.identity)
elif action == "unmute":
IdentityService(identity).unmute_from(self.request.identity)
elif action == "hide_boosts": elif action == "hide_boosts":
IdentityService(identity).follow_from(self.request.identity, boosts=False) IdentityService(identity).follow_from(self.request.identity, boosts=False)
elif action == "show_boosts": elif action == "show_boosts":