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

View File

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

View File

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

View File

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

View File

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

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">
{% if request.identity == identity %}
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile">
<i class="fa-solid fa-user-edit"></i>
</a>
{% else %}
{% if reverse_follow %}
<span class="reverse-follow">Follows You</span>
{% elif not inbound_block %}
{% if inbound_follow or outbound_mute %}
<span class="reverse-follow">
{% if inbound_follow %}Follows You{% endif %}{% if inbound_follow and outbound_mute %},{% endif %}
{% if outbound_mute %}Muted{% endif %}
</span>
{% endif %}
<form action="{{ identity.urls.action }}" method="POST" class="inline-menu">
{% 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">
<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>
{% else %}
<input type="hidden" name="action" value="follow">
@ -21,33 +29,50 @@
{% endif %}
</form>
{% 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">
<i class="fa-solid fa-bars"></i>
</a>
<menu>
{% if follow %}
<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>
</a>
<menu>
{% if outbound_follow %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
{% if outbound_follow.boosts %}
<input type="hidden" name="action" value="hide_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button>
{% else %}
<input type="hidden" name="action" value="show_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Show boosts</button>
{% endif %}
</form>
{% 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 %}
{% if follow.boosts %}
<input type="hidden" name="action" value="hide_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button>
{% else %}
<input type="hidden" name="action" value="show_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Show boosts</button>
{% endif %}
<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 %}
{% if request.user.admin %}
<a href="{{ identity.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-user-gear"></i> View in Admin
</a>
<a href="{{ identity.urls.djadmin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View in djadmin
</a>
{% endif %}
</menu>
{% endif %}
{% endif %}
{% if request.user.admin %}
<a href="{{ identity.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-user-gear"></i> View in Admin
</a>
<a href="{{ identity.urls.djadmin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View in djadmin
</a>
{% endif %}
</menu>
</div>
</div>

View File

@ -40,68 +40,75 @@
</small>
</h1>
{% if identity.summary %}
<div class="bio">
{{ identity.safe_summary }}
</div>
{% endif %}
{% if inbound_block %}
<p class="system-note">
This user has blocked you.
</p>
{% else %}
{% if identity.metadata %}
<div class="identity-metadata">
{% for entry in identity.safe_metadata %}
<div class="metadata-pair">
<span class="metadata-name">{{ entry.name }}</span>
<span class="metadata-value">{{ entry.value }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if identity.local and identity.config_identity.visible_follows %}
<div class="view-options follows">
<a href="{{ identity.urls.view }}" {% if not follows_page %}class="selected"{% endif %}><strong>{{ post_count }}</strong> posts</a>
<a href="{{ identity.urls.following }}" {% if not inbound and follows_page %}class="selected"{% endif %}><strong>{{ following_count }}</strong> following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and follows_page %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> follower{{ followers_count|pluralize }}</a>
</div>
{% endif %}
{% if not identity.local %}
{% if identity.outdated and not identity.name %}
<p class="system-note">
The system is still fetching this profile. Refresh to see updates.
</p>
{% else %}
<p class="system-note">
This is a member of another server.
<a href="{{ identity.profile_uri|default:identity.actor_uri }}">See their original profile ➔</a>
</p>
{% endif %}
{% endif %}
{% block subcontent %}
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}
<span class="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
<a href="{{ identity.profile_uri }}">their original profile ➔</a>
{% endif %}
{% endif %}
</span>
{% endfor %}
{% if page_obj.has_next %}
<div class="pagination">
<a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a>
{% if identity.summary %}
<div class="bio">
{{ identity.safe_summary }}
</div>
{% endif %}
{% endblock %}
{% if identity.metadata %}
<div class="identity-metadata">
{% for entry in identity.safe_metadata %}
<div class="metadata-pair">
<span class="metadata-name">{{ entry.name }}</span>
<span class="metadata-value">{{ entry.value }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if identity.local and identity.config_identity.visible_follows %}
<div class="view-options follows">
<a href="{{ identity.urls.view }}" {% if not follows_page %}class="selected"{% endif %}><strong>{{ post_count }}</strong> posts</a>
<a href="{{ identity.urls.following }}" {% if not inbound and follows_page %}class="selected"{% endif %}><strong>{{ following_count }}</strong> following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and follows_page %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> follower{{ followers_count|pluralize }}</a>
</div>
{% endif %}
{% if not identity.local %}
{% if identity.outdated and not identity.name %}
<p class="system-note">
The system is still fetching this profile. Refresh to see updates.
</p>
{% else %}
<p class="system-note">
This is a member of another server.
<a href="{{ identity.profile_uri|default:identity.actor_uri }}">See their original profile ➔</a>
</p>
{% endif %}
{% endif %}
{% block subcontent %}
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}
<span class="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
<a href="{{ identity.profile_uri }}">their original profile ➔</a>
{% endif %}
{% endif %}
</span>
{% endfor %}
{% if page_obj.has_next %}
<div class="pagination">
<a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a>
</div>
{% endif %}
{% endblock %}
{% endif %}
{% endblock %}

View File

@ -2,7 +2,7 @@ import pytest
from asgiref.sync import async_to_sync
from activities.models import Post
from users.models import Domain, Follow, Identity
from users.models import Block, Domain, Follow, Identity
@pytest.mark.django_db
@ -158,3 +158,26 @@ def test_post_followers(identity, other_identity, remote_identity):
post.save()
targets = async_to_sync(post.aget_targets)()
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 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"]

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

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

View File

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

View File

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

View File

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

View File

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