diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index bb9ee1a..53cb4fb 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -5,15 +5,17 @@ from django.db import models from activities.models.timeline_event import TimelineEvent from core.ld import canonicalise from stator.models import State, StateField, StateGraph, StatorModel -from users.models import FollowStates +from users.models import Block, FollowStates class FanOutStates(StateGraph): new = State(try_interval=600) sent = State(delete_after=86400) + skipped = State(delete_after=86400) failed = State(delete_after=86400) new.transitions_to(sent) + new.transitions_to(skipped) new.times_out_to(failed, seconds=86400 * 3) @classmethod @@ -32,6 +34,13 @@ class FanOutStates(StateGraph): # Handle creating/updating local posts case ((FanOut.Types.post | FanOut.Types.post_edited), True): 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 # 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, @@ -126,6 +135,28 @@ class FanOutStates(StateGraph): # Handle local boosts/likes case (FanOut.Types.interaction, True): 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 await sync_to_async(TimelineEvent.add_post_interaction)( identity=fan_out.identity, diff --git a/activities/models/post.py b/activities/models/post.py index b18e652..06d8de7 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -712,6 +712,14 @@ class Post(StatorModel): # If it's a local post, include the author if self.local: 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 # shared inbox) deduped_targets = set() diff --git a/api/views/accounts.py b/api/views/accounts.py index f53ee50..aace5fd 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -1,7 +1,7 @@ from django.db.models import Q from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 -from ninja import Field +from ninja import Field, Schema from activities.services import SearchService from api import schemas @@ -199,6 +199,51 @@ def account_unfollow(request, id: str): 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]) def account_following( request: HttpRequest, diff --git a/api/views/instance.py b/api/views/instance.py index 7f932f1..9e2104c 100644 --- a/api/views/instance.py +++ b/api/views/instance.py @@ -20,7 +20,7 @@ def instance_info(request): "urls": {}, "stats": { "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(), }, "thumbnail": Config.system.site_banner, diff --git a/static/css/style.css b/static/css/style.css index 312a837..6fbe788 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1078,6 +1078,11 @@ button.htmx-request::before, 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 { padding: 2px 6px; diff --git a/templates/identity/_view_menu.html b/templates/identity/_view_menu.html index 3edfcc3..a88ff7e 100644 --- a/templates/identity/_view_menu.html +++ b/templates/identity/_view_menu.html @@ -1,19 +1,27 @@ -
+ This user has blocked you. +
+ {% else %} - {% if identity.metadata %} - - {% endif %} - - {% if identity.local and identity.config_identity.visible_follows %} - - {% endif %} - - {% if not identity.local %} - {% if identity.outdated and not identity.name %} -- The system is still fetching this profile. Refresh to see updates. -
- {% else %} -- This is a member of another server. - See their original profile ➔ -
- {% endif %} - {% endif %} - - {% block subcontent %} - - {% for post in page_obj %} - {% include "activities/_post.html" %} - {% empty %} - - {% if identity.local %} - No posts yet. - {% else %} - No posts have been received/retrieved by this server yet. - - {% if identity.profile_uri %} - You might find historical posts at - their original profile ➔ - {% endif %} - {% endif %} - - {% endfor %} - - {% if page_obj.has_next %} -+ The system is still fetching this profile. Refresh to see updates. +
+ {% else %} ++ This is a member of another server. + See their original profile ➔ +
+ {% endif %} + {% endif %} + + {% block subcontent %} + + {% for post in page_obj %} + {% include "activities/_post.html" %} + {% empty %} + + {% if identity.local %} + No posts yet. + {% else %} + No posts have been received/retrieved by this server yet. + + {% if identity.profile_uri %} + You might find historical posts at + their original profile ➔ + {% endif %} + {% endif %} + + {% endfor %} + + {% if page_obj.has_next %} +Hello @test and @other
", + 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} diff --git a/tests/activities/models/test_timeline_event.py b/tests/activities/models/test_timeline_event.py new file mode 100644 index 0000000..e84c178 --- /dev/null +++ b/tests/activities/models/test_timeline_event.py @@ -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 diff --git a/users/admin.py b/users/admin.py index 6fd91fb..c604643 100644 --- a/users/admin.py +++ b/users/admin.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ from activities.admin import IdentityLocalFilter from users.models import ( Announcement, + Block, Domain, Follow, Identity, @@ -158,6 +159,16 @@ class FollowAdmin(admin.ModelAdmin): 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) class PasswordResetAdmin(admin.ModelAdmin): list_display = ["id", "user", "created"] diff --git a/users/migrations/0012_block_states.py b/users/migrations/0012_block_states.py new file mode 100644 index 0000000..ff8ccc0 --- /dev/null +++ b/users/migrations/0012_block_states.py @@ -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")}, + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 3f146ec..f564ed8 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -1,5 +1,5 @@ from .announcement import Announcement # noqa -from .block import Block # noqa +from .block import Block, BlockStates # noqa from .domain import Domain # noqa from .follow import Follow, FollowStates # noqa from .identity import Identity, IdentityStates # noqa diff --git a/users/models/block.py b/users/models/block.py index d312363..2391f54 100644 --- a/users/models/block.py +++ b/users/models/block.py @@ -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) """ + state = StateField(BlockStates) + source = models.ForeignKey( "users.Identity", on_delete=models.CASCADE, @@ -18,13 +118,209 @@ class Block(models.Model): 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 # 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() + include_notifications = models.BooleanField(default=False) expires = models.DateTimeField(blank=True, null=True) note = models.TextField(blank=True, null=True) created = models.DateTimeField(auto_now_add=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() diff --git a/users/models/follow.py b/users/models/follow.py index 019530a..3ef4499 100644 --- a/users/models/follow.py +++ b/users/models/follow.py @@ -97,6 +97,20 @@ class FollowStates(StateGraph): 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): """ When one user (the source) follows other (the target) @@ -127,6 +141,8 @@ class Follow(StatorModel): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + objects = FollowManager() + class Meta: unique_together = [("source", "target")] @@ -136,12 +152,15 @@ class Follow(StatorModel): ### Alternate fetchers/constructors ### @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 """ try: - return Follow.objects.get(source=source, target=target) + if require_active: + return Follow.objects.active().get(source=source, target=target) + else: + return Follow.objects.get(source=source, target=target) except Follow.DoesNotExist: return None @@ -157,19 +176,30 @@ class Follow(StatorModel): raise ValueError("You cannot initiate follows from a remote Identity") try: follow = Follow.objects.get(source=source, target=target) - if follow.boosts != boosts: - follow.boosts = boosts - follow.save() - except Follow.DoesNotExist: - follow = Follow.objects.create( - source=source, target=target, boosts=boosts, uri="" - ) - follow.uri = source.actor_uri + f"follow/{follow.pk}/" - # TODO: Local follow approvals - if target.local: - follow.state = FollowStates.accepted - TimelineEvent.add_follow(follow.target, follow.source) + if not follow.active: + follow.state = ( + FollowStates.accepted if target.local else FollowStates.unrequested + ) + follow.boosts = boosts follow.save() + except Follow.DoesNotExist: + with transaction.atomic(): + follow = Follow.objects.create( + 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}/" + # TODO: Local follow approvals + if target.local: + TimelineEvent.add_follow(follow.target, follow.source) + follow.save() return follow ### Async helpers ### @@ -182,12 +212,16 @@ class Follow(StatorModel): "source", "source__domain", "target" ).aget(pk=self.pk) - ### Helper properties ### + ### Properties ### @property def pending(self): return self.state in [FollowStates.unrequested, FollowStates.local_requested] + @property + def active(self): + return self.state in FollowStates.group_active() + ### ActivityPub (outbound) ### def to_ap(self): @@ -226,32 +260,40 @@ class Follow(StatorModel): ### ActivityPub (inbound) ### @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. - Raises KeyError if it's not found and create is False. + Raises DoesNotExist if it's not found and create is False. """ - # Resolve source and target and see if a Follow exists - source = Identity.by_actor_uri(data["actor"], create=create) - target = Identity.by_actor_uri(get_str_or_id(data["object"])) - follow = cls.maybe_get(source=source, target=target) - # If it doesn't exist, create one in the remote_requested state - if follow is None: - if create: - return cls.objects.create( - source=source, - target=target, - uri=data["id"], - state=FollowStates.remote_requested, - ) - else: - raise KeyError( - f"No follow with source {source} and target {target}", data - ) + # 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: - return follow + # Resolve source and target and see if a Follow exists + source = Identity.by_actor_uri(data["actor"], create=create) + target = Identity.by_actor_uri(get_str_or_id(data["object"])) + follow = cls.maybe_get(source=source, target=target) + # If it doesn't exist, create one in the remote_requested state + if follow is None: + if create: + return cls.objects.create( + source=source, + target=target, + uri=data["id"], + state=FollowStates.remote_requested, + ) + else: + raise cls.DoesNotExist( + f"No follow with source {source} and target {target}", data + ) + else: + return follow @classmethod def handle_request_ap(cls, data): @@ -272,14 +314,14 @@ class Follow(StatorModel): """ 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) try: follow = cls.by_ap(data["object"]) - except KeyError: + except cls.DoesNotExist: 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 follow and follow.state in [ FollowStates.unrequested, @@ -288,55 +330,33 @@ class Follow(StatorModel): follow.transition_perform(FollowStates.accepted) @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 - only an object URI reference. + Handles an incoming Follow Reject for one of our follows """ - # Ensure the object ref is in a format we expect - bits = data["object"].strip("/").split("/") - if bits[-2] != "follow": - raise ValueError(f"Unknown Follow object URI in Accept: {data['object']}") - # Retrieve the object by PK - follow = cls.objects.get(pk=bits[-1]) - # Ensure it's from the right actor + # Resolve source and target and see if a Follow exists (it really should) + try: + follow = cls.by_ap(data["object"]) + except cls.DoesNotExist: + raise ValueError("No Follow locally for incoming Reject", 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 follow.state in [ - FollowStates.unrequested, - FollowStates.local_requested, - ]: - follow.transition_perform(FollowStates.accepted) + raise ValueError("Reject actor does not match its Follow object", data) + # Mark the follow rejected + follow.transition_perform(FollowStates.rejected) @classmethod def handle_undo_ap(cls, data): """ 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) try: follow = cls.by_ap(data["object"]) - except KeyError: + except cls.DoesNotExist: 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 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) diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py index baa81c8..c50de37 100644 --- a/users/models/inbox_message.py +++ b/users/models/inbox_message.py @@ -15,11 +15,13 @@ class InboxMessageStates(StateGraph): @classmethod async def handle_received(cls, instance: "InboxMessage"): 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: case "follow": await sync_to_async(Follow.handle_request_ap)(instance.message) + case "block": + await sync_to_async(Block.handle_ap)(instance.message) case "announce": await sync_to_async(PostInteraction.handle_ap)(instance.message) case "like": @@ -65,9 +67,8 @@ class InboxMessageStates(StateGraph): case "follow": await sync_to_async(Follow.handle_accept_ap)(instance.message) case None: - await sync_to_async(Follow.handle_accept_ref_ap)( - instance.message - ) + # It's a string object, but these will only be for Follows + await sync_to_async(Follow.handle_accept_ap)(instance.message) case unknown: raise ValueError( f"Cannot handle activity of type accept.{unknown}" @@ -76,6 +77,9 @@ class InboxMessageStates(StateGraph): match instance.message_object_type: case "follow": 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: raise ValueError( f"Cannot handle activity of type reject.{unknown}" @@ -84,6 +88,8 @@ class InboxMessageStates(StateGraph): match instance.message_object_type: case "follow": await sync_to_async(Follow.handle_undo_ap)(instance.message) + case "block": + await sync_to_async(Block.handle_undo_ap)(instance.message) case "like": await sync_to_async(PostInteraction.handle_undo_ap)( instance.message diff --git a/users/services/identity.py b/users/services/identity.py index ec2ed12..8629941 100644 --- a/users/services/identity.py +++ b/users/services/identity.py @@ -1,10 +1,8 @@ -from typing import cast - from django.db import models from django.template.defaultfilters import linebreaks_filter from core.html import strip_html -from users.models import Follow, FollowStates, Identity +from users.models import Block, BlockStates, Follow, FollowStates, Identity class IdentityService: @@ -36,16 +34,7 @@ class IdentityService: Follows a user (or does nothing if already followed). 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) - 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) + return Follow.create_local(from_identity, self.identity, boosts=boosts) def unfollow_from(self, from_identity: Identity): """ @@ -55,34 +44,95 @@ class IdentityService: if existing_follow: 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): """ Returns a Relationship object for the from_identity's relationship with this identity. """ - - follow = self.identity.inbound_follows.filter( - source=from_identity, - state__in=FollowStates.group_active(), - ).first() - + relationships = self.relationships(from_identity) return { "id": self.identity.pk, - "following": follow is not None, - "followed_by": self.identity.outbound_follows.filter( - target=from_identity, - state__in=FollowStates.group_active(), - ).exists(), - "showing_reblogs": follow and follow.boosts or False, + "following": relationships["outbound_follow"] is not None, + "followed_by": relationships["inbound_follow"] is not None, + "showing_reblogs": ( + relationships["outbound_follow"] + and relationships["outbound_follow"].boosts + or False + ), "notifying": False, - "blocking": False, - "blocked_by": False, - "muting": False, + "blocking": relationships["outbound_block"] is not None, + "blocked_by": relationships["inbound_block"] is not None, + "muting": relationships["outbound_mute"] is not None, "muting_notifications": False, "requested": False, "domain_blocking": 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): diff --git a/users/views/identity.py b/users/views/identity.py index 9102e95..7d94836 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -18,7 +18,7 @@ from core.decorators import cache_page, cache_page_by_ap_json from core.ld import canonicalise from core.models import Config 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.shortcuts import by_handle_or_404 @@ -70,8 +70,6 @@ class ViewIdentity(ListView): def get_context_data(self): context = super().get_context_data() context["identity"] = self.identity - context["follow"] = None - context["reverse_follow"] = None context["interactions"] = PostInteraction.get_post_interactions( context["page_obj"], self.request.identity, @@ -85,12 +83,9 @@ class ViewIdentity(ListView): state__in=FollowStates.group_active() ).count() if self.request.identity: - follow = Follow.maybe_get(self.request.identity, self.identity) - if follow and follow.state in FollowStates.group_active(): - 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 + context.update( + IdentityService(self.identity).relationships(self.request.identity) + ) return context @@ -257,6 +252,14 @@ class ActionIdentity(View): IdentityService(identity).follow_from(self.request.identity) elif action == "unfollow": 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": IdentityService(identity).follow_from(self.request.identity, boosts=False) elif action == "show_boosts":