Fixed #408: Implemented blocking
This commit is contained in:
parent
f689110e0b
commit
b44be55609
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
|
@ -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"]
|
||||
|
|
|
@ -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")},
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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":
|
||||
|
|
Loading…
Reference in New Issue