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 activities.models.timeline_event import TimelineEvent
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
from stator.models import State, StateField, StateGraph, StatorModel
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
from users.models import FollowStates
|
from users.models import Block, FollowStates
|
||||||
|
|
||||||
|
|
||||||
class FanOutStates(StateGraph):
|
class FanOutStates(StateGraph):
|
||||||
new = State(try_interval=600)
|
new = State(try_interval=600)
|
||||||
sent = State(delete_after=86400)
|
sent = State(delete_after=86400)
|
||||||
|
skipped = State(delete_after=86400)
|
||||||
failed = State(delete_after=86400)
|
failed = State(delete_after=86400)
|
||||||
|
|
||||||
new.transitions_to(sent)
|
new.transitions_to(sent)
|
||||||
|
new.transitions_to(skipped)
|
||||||
new.times_out_to(failed, seconds=86400 * 3)
|
new.times_out_to(failed, seconds=86400 * 3)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -32,6 +34,13 @@ class FanOutStates(StateGraph):
|
||||||
# Handle creating/updating local posts
|
# Handle creating/updating local posts
|
||||||
case ((FanOut.Types.post | FanOut.Types.post_edited), True):
|
case ((FanOut.Types.post | FanOut.Types.post_edited), True):
|
||||||
post = await fan_out.subject_post.afetch_full()
|
post = await fan_out.subject_post.afetch_full()
|
||||||
|
# If the author of the post is blocked or muted, skip out
|
||||||
|
if (
|
||||||
|
await Block.objects.active()
|
||||||
|
.filter(source=fan_out.identity, target=post.author)
|
||||||
|
.aexists()
|
||||||
|
):
|
||||||
|
return cls.skipped
|
||||||
# Make a timeline event directly
|
# Make a timeline event directly
|
||||||
# If it's a reply, we only add it if we follow at least one
|
# If it's a reply, we only add it if we follow at least one
|
||||||
# of the people mentioned AND the author, or we're mentioned,
|
# of the people mentioned AND the author, or we're mentioned,
|
||||||
|
@ -126,6 +135,28 @@ class FanOutStates(StateGraph):
|
||||||
# Handle local boosts/likes
|
# Handle local boosts/likes
|
||||||
case (FanOut.Types.interaction, True):
|
case (FanOut.Types.interaction, True):
|
||||||
interaction = await fan_out.subject_post_interaction.afetch_full()
|
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||||
|
# If the author of the interaction is blocked or their notifications
|
||||||
|
# are muted, skip out
|
||||||
|
if (
|
||||||
|
await Block.objects.active()
|
||||||
|
.filter(
|
||||||
|
models.Q(mute=False) | models.Q(include_notifications=True),
|
||||||
|
source=fan_out.identity,
|
||||||
|
target=interaction.identity,
|
||||||
|
)
|
||||||
|
.aexists()
|
||||||
|
):
|
||||||
|
return cls.skipped
|
||||||
|
# If blocked/muted the underlying post author, skip out
|
||||||
|
if (
|
||||||
|
await Block.objects.active()
|
||||||
|
.filter(
|
||||||
|
source=fan_out.identity,
|
||||||
|
target_id=interaction.post.author_id,
|
||||||
|
)
|
||||||
|
.aexists()
|
||||||
|
):
|
||||||
|
return cls.skipped
|
||||||
# Make a timeline event directly
|
# Make a timeline event directly
|
||||||
await sync_to_async(TimelineEvent.add_post_interaction)(
|
await sync_to_async(TimelineEvent.add_post_interaction)(
|
||||||
identity=fan_out.identity,
|
identity=fan_out.identity,
|
||||||
|
|
|
@ -712,6 +712,14 @@ class Post(StatorModel):
|
||||||
# If it's a local post, include the author
|
# If it's a local post, include the author
|
||||||
if self.local:
|
if self.local:
|
||||||
targets.add(self.author)
|
targets.add(self.author)
|
||||||
|
# Fetch the author's full blocks and remove them as targets
|
||||||
|
blocks = (
|
||||||
|
self.author.outbound_blocks.active()
|
||||||
|
.filter(mute=False)
|
||||||
|
.select_related("target")
|
||||||
|
)
|
||||||
|
async for block in blocks:
|
||||||
|
targets.remove(block.target)
|
||||||
# Now dedupe the targets based on shared inboxes (we only keep one per
|
# Now dedupe the targets based on shared inboxes (we only keep one per
|
||||||
# shared inbox)
|
# shared inbox)
|
||||||
deduped_targets = set()
|
deduped_targets = set()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from ninja import Field
|
from ninja import Field, Schema
|
||||||
|
|
||||||
from activities.services import SearchService
|
from activities.services import SearchService
|
||||||
from api import schemas
|
from api import schemas
|
||||||
|
@ -199,6 +199,51 @@ def account_unfollow(request, id: str):
|
||||||
return service.mastodon_json_relationship(request.identity)
|
return service.mastodon_json_relationship(request.identity)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/v1/accounts/{id}/block", response=schemas.Relationship)
|
||||||
|
@identity_required
|
||||||
|
def account_block(request, id: str):
|
||||||
|
identity = get_object_or_404(Identity, pk=id)
|
||||||
|
service = IdentityService(identity)
|
||||||
|
service.block_from(request.identity)
|
||||||
|
return service.mastodon_json_relationship(request.identity)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/v1/accounts/{id}/unblock", response=schemas.Relationship)
|
||||||
|
@identity_required
|
||||||
|
def account_unblock(request, id: str):
|
||||||
|
identity = get_object_or_404(Identity, pk=id)
|
||||||
|
service = IdentityService(identity)
|
||||||
|
service.unblock_from(request.identity)
|
||||||
|
return service.mastodon_json_relationship(request.identity)
|
||||||
|
|
||||||
|
|
||||||
|
class MuteDetailsSchema(Schema):
|
||||||
|
notifications: bool = True
|
||||||
|
duration: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/v1/accounts/{id}/mute", response=schemas.Relationship)
|
||||||
|
@identity_required
|
||||||
|
def account_mute(request, id: str, details: MuteDetailsSchema):
|
||||||
|
identity = get_object_or_404(Identity, pk=id)
|
||||||
|
service = IdentityService(identity)
|
||||||
|
service.mute_from(
|
||||||
|
request.identity,
|
||||||
|
duration=details.duration,
|
||||||
|
include_notifications=details.notifications,
|
||||||
|
)
|
||||||
|
return service.mastodon_json_relationship(request.identity)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/v1/accounts/{id}/unmute", response=schemas.Relationship)
|
||||||
|
@identity_required
|
||||||
|
def account_unmute(request, id: str):
|
||||||
|
identity = get_object_or_404(Identity, pk=id)
|
||||||
|
service = IdentityService(identity)
|
||||||
|
service.unmute_from(request.identity)
|
||||||
|
return service.mastodon_json_relationship(request.identity)
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/v1/accounts/{id}/following", response=list[schemas.Account])
|
@api_router.get("/v1/accounts/{id}/following", response=list[schemas.Account])
|
||||||
def account_following(
|
def account_following(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
|
|
|
@ -20,7 +20,7 @@ def instance_info(request):
|
||||||
"urls": {},
|
"urls": {},
|
||||||
"stats": {
|
"stats": {
|
||||||
"user_count": Identity.objects.filter(local=True).count(),
|
"user_count": Identity.objects.filter(local=True).count(),
|
||||||
"status_count": Post.objects.filter(local=True).count(),
|
"status_count": Post.objects.filter(local=True).not_hidden().count(),
|
||||||
"domain_count": Domain.objects.count(),
|
"domain_count": Domain.objects.count(),
|
||||||
},
|
},
|
||||||
"thumbnail": Config.system.site_banner,
|
"thumbnail": Config.system.site_banner,
|
||||||
|
|
|
@ -1078,6 +1078,11 @@ button.htmx-request::before,
|
||||||
animation-timing-function: var(--fa-animation-timing, linear);
|
animation-timing-function: var(--fa-animation-timing, linear);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button i:first-child,
|
||||||
|
.button i:first-child {
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.right-column button,
|
.right-column button,
|
||||||
.right-column .button {
|
.right-column .button {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|
|
@ -1,19 +1,27 @@
|
||||||
<div class="inline follow-profile {% if reverse_follow %}has-reverse{% endif %}">
|
<div class="inline follow-profile {% if inbound_follow %}has-reverse{% endif %}">
|
||||||
<div class="actions" role="menubar">
|
<div class="actions" role="menubar">
|
||||||
{% if request.identity == identity %}
|
{% if request.identity == identity %}
|
||||||
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile">
|
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile">
|
||||||
<i class="fa-solid fa-user-edit"></i>
|
<i class="fa-solid fa-user-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% elif not inbound_block %}
|
||||||
{% if reverse_follow %}
|
{% if inbound_follow or outbound_mute %}
|
||||||
<span class="reverse-follow">Follows You</span>
|
<span class="reverse-follow">
|
||||||
|
{% if inbound_follow %}Follows You{% endif %}{% if inbound_follow and outbound_mute %},{% endif %}
|
||||||
|
{% if outbound_mute %}Muted{% endif %}
|
||||||
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="{{ identity.urls.action }}" method="POST" class="inline-menu">
|
<form action="{{ identity.urls.action }}" method="POST" class="inline-menu">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if follow %}
|
{% if outbound_block %}
|
||||||
|
<input type="hidden" name="action" value="unblock">
|
||||||
|
<button class="destructive" title="Unblock"><i class="fa-solid fa-ban"></i>
|
||||||
|
Unblock
|
||||||
|
</button>
|
||||||
|
{% elif outbound_follow %}
|
||||||
<input type="hidden" name="action" value="unfollow">
|
<input type="hidden" name="action" value="unfollow">
|
||||||
<button class="destructive" title="Unfollow"><i class="fa-solid fa-user-minus"></i>
|
<button class="destructive" title="Unfollow"><i class="fa-solid fa-user-minus"></i>
|
||||||
{% if follow.pending %}Follow Pending{% else %}Unfollow{% endif %}
|
{% if outbound_follow.pending %}Follow Pending{% else %}Unfollow{% endif %}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="hidden" name="action" value="follow">
|
<input type="hidden" name="action" value="follow">
|
||||||
|
@ -21,16 +29,14 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user.admin %}
|
|
||||||
<a title="Menu" class="menu button" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" aria-haspopup="menu" tabindex="0">
|
<a title="Menu" class="menu button" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" aria-haspopup="menu" tabindex="0">
|
||||||
<i class="fa-solid fa-bars"></i>
|
<i class="fa-solid fa-bars"></i>
|
||||||
</a>
|
</a>
|
||||||
<menu>
|
<menu>
|
||||||
{% if follow %}
|
{% if outbound_follow %}
|
||||||
<form action="{{ identity.urls.action }}" method="POST" class="inline">
|
<form action="{{ identity.urls.action }}" method="POST" class="inline">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if follow.boosts %}
|
{% if outbound_follow.boosts %}
|
||||||
<input type="hidden" name="action" value="hide_boosts">
|
<input type="hidden" name="action" value="hide_boosts">
|
||||||
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button>
|
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -39,6 +45,26 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if not outbound_block %}
|
||||||
|
<form action="{{ identity.urls.action }}" method="POST" class="inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="block">
|
||||||
|
<button role="menuitem"><i class="fa-solid fa-ban"></i> Block user</button>
|
||||||
|
</form>
|
||||||
|
{% if outbound_mute %}
|
||||||
|
<form action="{{ identity.urls.action }}" method="POST" class="inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="unmute">
|
||||||
|
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Unmute user</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form action="{{ identity.urls.action }}" method="POST" class="inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="mute">
|
||||||
|
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Mute user</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if request.user.admin %}
|
{% if request.user.admin %}
|
||||||
<a href="{{ identity.urls.admin_edit }}" role="menuitem">
|
<a href="{{ identity.urls.admin_edit }}" role="menuitem">
|
||||||
<i class="fa-solid fa-user-gear"></i> View in Admin
|
<i class="fa-solid fa-user-gear"></i> View in Admin
|
||||||
|
@ -48,6 +74,5 @@
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</menu>
|
</menu>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -40,6 +40,12 @@
|
||||||
</small>
|
</small>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{% if inbound_block %}
|
||||||
|
<p class="system-note">
|
||||||
|
This user has blocked you.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
{% if identity.summary %}
|
{% if identity.summary %}
|
||||||
<div class="bio">
|
<div class="bio">
|
||||||
{{ identity.safe_summary }}
|
{{ identity.safe_summary }}
|
||||||
|
@ -104,4 +110,5 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import pytest
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
from activities.models import Post
|
from activities.models import Post
|
||||||
from users.models import Domain, Follow, Identity
|
from users.models import Block, Domain, Follow, Identity
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@ -158,3 +158,26 @@ def test_post_followers(identity, other_identity, remote_identity):
|
||||||
post.save()
|
post.save()
|
||||||
targets = async_to_sync(post.aget_targets)()
|
targets = async_to_sync(post.aget_targets)()
|
||||||
assert targets == {identity}
|
assert targets == {identity}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_post_blocked(identity, other_identity, remote_identity):
|
||||||
|
"""
|
||||||
|
Blocked users should never get a copy of a post even if they're mentioned.
|
||||||
|
"""
|
||||||
|
# Block the two other identities, one with mute only
|
||||||
|
Block.create_local_mute(identity, other_identity)
|
||||||
|
Block.create_local_block(identity, remote_identity)
|
||||||
|
|
||||||
|
# Make a post
|
||||||
|
post = Post.objects.create(
|
||||||
|
content="<p>Hello @test and @other</p>",
|
||||||
|
author=identity,
|
||||||
|
local=True,
|
||||||
|
)
|
||||||
|
post.mentions.add(remote_identity)
|
||||||
|
post.mentions.add(other_identity)
|
||||||
|
|
||||||
|
# The muted block should be in targets, the full block should not
|
||||||
|
targets = async_to_sync(post.aget_targets)()
|
||||||
|
assert targets == {identity, other_identity}
|
||||||
|
|
|
@ -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 activities.admin import IdentityLocalFilter
|
||||||
from users.models import (
|
from users.models import (
|
||||||
Announcement,
|
Announcement,
|
||||||
|
Block,
|
||||||
Domain,
|
Domain,
|
||||||
Follow,
|
Follow,
|
||||||
Identity,
|
Identity,
|
||||||
|
@ -158,6 +159,16 @@ class FollowAdmin(admin.ModelAdmin):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Block)
|
||||||
|
class BlockAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "source", "target", "mute", "state"]
|
||||||
|
list_filter = [LocalSourceFilter, LocalTargetFilter, "state"]
|
||||||
|
raw_id_fields = ["source", "target"]
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PasswordReset)
|
@admin.register(PasswordReset)
|
||||||
class PasswordResetAdmin(admin.ModelAdmin):
|
class PasswordResetAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "user", "created"]
|
list_display = ["id", "user", "created"]
|
||||||
|
|
|
@ -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 .announcement import Announcement # noqa
|
||||||
from .block import Block # noqa
|
from .block import Block, BlockStates # noqa
|
||||||
from .domain import Domain # noqa
|
from .domain import Domain # noqa
|
||||||
from .follow import Follow, FollowStates # noqa
|
from .follow import Follow, FollowStates # noqa
|
||||||
from .identity import Identity, IdentityStates # noqa
|
from .identity import Identity, IdentityStates # noqa
|
||||||
|
|
|
@ -1,11 +1,111 @@
|
||||||
from django.db import models
|
import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from django.db import models, transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.ld import canonicalise, get_str_or_id
|
||||||
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
|
from users.models.identity import Identity
|
||||||
|
|
||||||
|
|
||||||
class Block(models.Model):
|
class BlockStates(StateGraph):
|
||||||
|
new = State(try_interval=600)
|
||||||
|
sent = State(externally_progressed=True)
|
||||||
|
awaiting_expiry = State(try_interval=60 * 60, attempt_immediately=False)
|
||||||
|
undone = State(try_interval=60 * 60, delete_after=86400 * 7)
|
||||||
|
undone_sent = State(delete_after=86400)
|
||||||
|
|
||||||
|
new.transitions_to(sent)
|
||||||
|
new.transitions_to(awaiting_expiry)
|
||||||
|
sent.transitions_to(undone)
|
||||||
|
awaiting_expiry.transitions_to(undone)
|
||||||
|
# We don't really care if the other end accepts our block
|
||||||
|
new.times_out_to(sent, seconds=86400 * 7)
|
||||||
|
undone.transitions_to(undone_sent)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def group_active(cls):
|
||||||
|
return [cls.new, cls.sent, cls.awaiting_expiry]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handle_new(cls, instance: "Block"):
|
||||||
|
"""
|
||||||
|
Block that are new need us to deliver the Block object
|
||||||
|
to the target server.
|
||||||
|
"""
|
||||||
|
# Mutes don't send but might need expiry
|
||||||
|
if instance.mute:
|
||||||
|
return cls.awaiting_expiry
|
||||||
|
# Fetch more info
|
||||||
|
block = await instance.afetch_full()
|
||||||
|
# Remote blocks should not be here, local blocks just work
|
||||||
|
if not block.source.local or block.target.local:
|
||||||
|
return cls.sent
|
||||||
|
# Don't try if the other identity didn't fetch yet
|
||||||
|
if not block.target.inbox_uri:
|
||||||
|
return
|
||||||
|
# Sign it and send it
|
||||||
|
try:
|
||||||
|
await block.source.signed_request(
|
||||||
|
method="post",
|
||||||
|
uri=block.target.inbox_uri,
|
||||||
|
body=canonicalise(block.to_ap()),
|
||||||
|
)
|
||||||
|
except httpx.RequestError:
|
||||||
|
return
|
||||||
|
return cls.sent
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handle_awaiting_expiry(cls, instance: "Block"):
|
||||||
|
"""
|
||||||
|
Checks to see if there is an expiry we should undo
|
||||||
|
"""
|
||||||
|
if instance.expires and instance.expires <= timezone.now():
|
||||||
|
return cls.undone
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handle_undone(cls, instance: "Block"):
|
||||||
|
"""
|
||||||
|
Delivers the Undo object to the target server
|
||||||
|
"""
|
||||||
|
block = await instance.afetch_full()
|
||||||
|
# Remote blocks should not be here, mutes don't send, local blocks just work
|
||||||
|
if not block.source.local or block.target.local or instance.mute:
|
||||||
|
return cls.undone_sent
|
||||||
|
try:
|
||||||
|
await block.source.signed_request(
|
||||||
|
method="post",
|
||||||
|
uri=block.target.inbox_uri,
|
||||||
|
body=canonicalise(block.to_undo_ap()),
|
||||||
|
)
|
||||||
|
except httpx.RequestError:
|
||||||
|
return
|
||||||
|
return cls.undone_sent
|
||||||
|
|
||||||
|
|
||||||
|
class BlockQuerySet(models.QuerySet):
|
||||||
|
def active(self):
|
||||||
|
query = self.filter(state__in=BlockStates.group_active())
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
class BlockManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return BlockQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
|
def active(self):
|
||||||
|
return self.get_queryset().active()
|
||||||
|
|
||||||
|
|
||||||
|
class Block(StatorModel):
|
||||||
"""
|
"""
|
||||||
When one user (the source) mutes or blocks another (the target)
|
When one user (the source) mutes or blocks another (the target)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
state = StateField(BlockStates)
|
||||||
|
|
||||||
source = models.ForeignKey(
|
source = models.ForeignKey(
|
||||||
"users.Identity",
|
"users.Identity",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
@ -18,13 +118,209 @@ class Block(models.Model):
|
||||||
related_name="inbound_blocks",
|
related_name="inbound_blocks",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
uri = models.CharField(blank=True, null=True, max_length=500)
|
||||||
|
|
||||||
# If it is a mute, we will stop delivering any activities from target to
|
# If it is a mute, we will stop delivering any activities from target to
|
||||||
# source, but we will still deliver activities from source to target.
|
# source, but we will still deliver activities from source to target.
|
||||||
# A full block (non-mute) stops activities both ways.
|
# A full block (mute=False) stops activities both ways.
|
||||||
mute = models.BooleanField()
|
mute = models.BooleanField()
|
||||||
|
include_notifications = models.BooleanField(default=False)
|
||||||
|
|
||||||
expires = models.DateTimeField(blank=True, null=True)
|
expires = models.DateTimeField(blank=True, null=True)
|
||||||
note = models.TextField(blank=True, null=True)
|
note = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
updated = models.DateTimeField(auto_now=True)
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = BlockManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("source", "target", "mute")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"#{self.id}: {self.source} blocks {self.target}"
|
||||||
|
|
||||||
|
### Alternate fetchers/constructors ###
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def maybe_get(
|
||||||
|
cls, source, target, mute=False, require_active=False
|
||||||
|
) -> Optional["Block"]:
|
||||||
|
"""
|
||||||
|
Returns a Block if it exists between source and target
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if require_active:
|
||||||
|
return cls.objects.active().get(source=source, target=target, mute=mute)
|
||||||
|
else:
|
||||||
|
return cls.objects.get(source=source, target=target, mute=mute)
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_local_block(cls, source, target) -> "Block":
|
||||||
|
"""
|
||||||
|
Creates or updates a full Block from a local Identity to the target
|
||||||
|
(which can be local or remote).
|
||||||
|
"""
|
||||||
|
if not source.local:
|
||||||
|
raise ValueError("You cannot block from a remote Identity")
|
||||||
|
block = cls.maybe_get(source=source, target=target, mute=False)
|
||||||
|
if block is not None:
|
||||||
|
if not block.active:
|
||||||
|
block.state = BlockStates.new # type:ignore
|
||||||
|
block.save()
|
||||||
|
else:
|
||||||
|
with transaction.atomic():
|
||||||
|
block = cls.objects.create(
|
||||||
|
source=source,
|
||||||
|
target=target,
|
||||||
|
mute=False,
|
||||||
|
)
|
||||||
|
block.uri = source.actor_uri + f"block/{block.pk}/"
|
||||||
|
block.save()
|
||||||
|
return block
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_local_mute(
|
||||||
|
cls,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
duration=None,
|
||||||
|
include_notifications=False,
|
||||||
|
) -> "Block":
|
||||||
|
"""
|
||||||
|
Creates or updates a muting Block from a local Identity to the target
|
||||||
|
(which can be local or remote).
|
||||||
|
"""
|
||||||
|
if not source.local:
|
||||||
|
raise ValueError("You cannot mute from a remote Identity")
|
||||||
|
block = cls.maybe_get(source=source, target=target, mute=True)
|
||||||
|
if block is not None:
|
||||||
|
if not block.active:
|
||||||
|
block.state = BlockStates.new # type:ignore
|
||||||
|
if duration:
|
||||||
|
block.expires = timezone.now() + datetime.timedelta(seconds=duration)
|
||||||
|
block.include_notifications = include_notifications
|
||||||
|
block.save()
|
||||||
|
else:
|
||||||
|
with transaction.atomic():
|
||||||
|
block = cls.objects.create(
|
||||||
|
source=source,
|
||||||
|
target=target,
|
||||||
|
mute=True,
|
||||||
|
include_notifications=include_notifications,
|
||||||
|
expires=(
|
||||||
|
timezone.now() + datetime.timedelta(seconds=duration)
|
||||||
|
if duration
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
block.uri = source.actor_uri + f"block/{block.pk}/"
|
||||||
|
block.save()
|
||||||
|
return block
|
||||||
|
|
||||||
|
### Properties ###
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self):
|
||||||
|
return self.state in BlockStates.group_active()
|
||||||
|
|
||||||
|
### Async helpers ###
|
||||||
|
|
||||||
|
async def afetch_full(self):
|
||||||
|
"""
|
||||||
|
Returns a version of the object with all relations pre-loaded
|
||||||
|
"""
|
||||||
|
return await Block.objects.select_related(
|
||||||
|
"source", "source__domain", "target"
|
||||||
|
).aget(pk=self.pk)
|
||||||
|
|
||||||
|
### ActivityPub (outbound) ###
|
||||||
|
|
||||||
|
def to_ap(self):
|
||||||
|
"""
|
||||||
|
Returns the AP JSON for this object
|
||||||
|
"""
|
||||||
|
if self.mute:
|
||||||
|
raise ValueError("Cannot send mutes over ActivityPub")
|
||||||
|
return {
|
||||||
|
"type": "Block",
|
||||||
|
"id": self.uri,
|
||||||
|
"actor": self.source.actor_uri,
|
||||||
|
"object": self.target.actor_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_undo_ap(self):
|
||||||
|
"""
|
||||||
|
Returns the AP JSON for this objects' undo.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"type": "Undo",
|
||||||
|
"id": self.uri + "#undo",
|
||||||
|
"actor": self.source.actor_uri,
|
||||||
|
"object": self.to_ap(),
|
||||||
|
}
|
||||||
|
|
||||||
|
### ActivityPub (inbound) ###
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def by_ap(cls, data: str | dict, create=False) -> "Block":
|
||||||
|
"""
|
||||||
|
Retrieves a Block instance by its ActivityPub JSON object or its URI.
|
||||||
|
|
||||||
|
Optionally creates one if it's not present.
|
||||||
|
Raises KeyError if it's not found and create is False.
|
||||||
|
"""
|
||||||
|
# If it's a string, do the reference resolve
|
||||||
|
if isinstance(data, str):
|
||||||
|
bits = data.strip("/").split("/")
|
||||||
|
if bits[-2] != "block":
|
||||||
|
raise ValueError(f"Unknown Block object URI: {data}")
|
||||||
|
return Block.objects.get(pk=bits[-1])
|
||||||
|
# Otherwise, do the object resolve
|
||||||
|
else:
|
||||||
|
# Resolve source and target and see if a Block exists
|
||||||
|
source = Identity.by_actor_uri(data["actor"], create=create)
|
||||||
|
target = Identity.by_actor_uri(get_str_or_id(data["object"]))
|
||||||
|
block = cls.maybe_get(source=source, target=target, mute=False)
|
||||||
|
# If it doesn't exist, create one in the sent state
|
||||||
|
if block is None:
|
||||||
|
if create:
|
||||||
|
return cls.objects.create(
|
||||||
|
source=source,
|
||||||
|
target=target,
|
||||||
|
uri=data["id"],
|
||||||
|
mute=False,
|
||||||
|
state=BlockStates.sent,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise cls.DoesNotExist(
|
||||||
|
f"No block with source {source} and target {target}", data
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return block
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def handle_ap(cls, data):
|
||||||
|
"""
|
||||||
|
Handles an incoming Block notification
|
||||||
|
"""
|
||||||
|
with transaction.atomic():
|
||||||
|
cls.by_ap(data, create=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def handle_undo_ap(cls, data):
|
||||||
|
"""
|
||||||
|
Handles an incoming Block Undo
|
||||||
|
"""
|
||||||
|
# Resolve source and target and see if a Follow exists (it hopefully does)
|
||||||
|
try:
|
||||||
|
block = cls.by_ap(data["object"])
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError("No Block locally for incoming Undo", data)
|
||||||
|
# Check the block's source is the actor
|
||||||
|
if data["actor"] != block.source.actor_uri:
|
||||||
|
raise ValueError("Undo actor does not match its Block object", data)
|
||||||
|
# Delete the follow
|
||||||
|
block.delete()
|
||||||
|
|
|
@ -97,6 +97,20 @@ class FollowStates(StateGraph):
|
||||||
return cls.undone_remotely
|
return cls.undone_remotely
|
||||||
|
|
||||||
|
|
||||||
|
class FollowQuerySet(models.QuerySet):
|
||||||
|
def active(self):
|
||||||
|
query = self.filter(state__in=FollowStates.group_active())
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
class FollowManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return FollowQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
|
def active(self):
|
||||||
|
return self.get_queryset().active()
|
||||||
|
|
||||||
|
|
||||||
class Follow(StatorModel):
|
class Follow(StatorModel):
|
||||||
"""
|
"""
|
||||||
When one user (the source) follows other (the target)
|
When one user (the source) follows other (the target)
|
||||||
|
@ -127,6 +141,8 @@ class Follow(StatorModel):
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
updated = models.DateTimeField(auto_now=True)
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = FollowManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [("source", "target")]
|
unique_together = [("source", "target")]
|
||||||
|
|
||||||
|
@ -136,11 +152,14 @@ class Follow(StatorModel):
|
||||||
### Alternate fetchers/constructors ###
|
### Alternate fetchers/constructors ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def maybe_get(cls, source, target) -> Optional["Follow"]:
|
def maybe_get(cls, source, target, require_active=False) -> Optional["Follow"]:
|
||||||
"""
|
"""
|
||||||
Returns a follow if it exists between source and target
|
Returns a follow if it exists between source and target
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if require_active:
|
||||||
|
return Follow.objects.active().get(source=source, target=target)
|
||||||
|
else:
|
||||||
return Follow.objects.get(source=source, target=target)
|
return Follow.objects.get(source=source, target=target)
|
||||||
except Follow.DoesNotExist:
|
except Follow.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
@ -157,17 +176,28 @@ class Follow(StatorModel):
|
||||||
raise ValueError("You cannot initiate follows from a remote Identity")
|
raise ValueError("You cannot initiate follows from a remote Identity")
|
||||||
try:
|
try:
|
||||||
follow = Follow.objects.get(source=source, target=target)
|
follow = Follow.objects.get(source=source, target=target)
|
||||||
if follow.boosts != boosts:
|
if not follow.active:
|
||||||
|
follow.state = (
|
||||||
|
FollowStates.accepted if target.local else FollowStates.unrequested
|
||||||
|
)
|
||||||
follow.boosts = boosts
|
follow.boosts = boosts
|
||||||
follow.save()
|
follow.save()
|
||||||
except Follow.DoesNotExist:
|
except Follow.DoesNotExist:
|
||||||
|
with transaction.atomic():
|
||||||
follow = Follow.objects.create(
|
follow = Follow.objects.create(
|
||||||
source=source, target=target, boosts=boosts, uri=""
|
source=source,
|
||||||
|
target=target,
|
||||||
|
boosts=boosts,
|
||||||
|
uri="",
|
||||||
|
state=(
|
||||||
|
FollowStates.accepted
|
||||||
|
if target.local
|
||||||
|
else FollowStates.unrequested
|
||||||
|
),
|
||||||
)
|
)
|
||||||
follow.uri = source.actor_uri + f"follow/{follow.pk}/"
|
follow.uri = source.actor_uri + f"follow/{follow.pk}/"
|
||||||
# TODO: Local follow approvals
|
# TODO: Local follow approvals
|
||||||
if target.local:
|
if target.local:
|
||||||
follow.state = FollowStates.accepted
|
|
||||||
TimelineEvent.add_follow(follow.target, follow.source)
|
TimelineEvent.add_follow(follow.target, follow.source)
|
||||||
follow.save()
|
follow.save()
|
||||||
return follow
|
return follow
|
||||||
|
@ -182,12 +212,16 @@ class Follow(StatorModel):
|
||||||
"source", "source__domain", "target"
|
"source", "source__domain", "target"
|
||||||
).aget(pk=self.pk)
|
).aget(pk=self.pk)
|
||||||
|
|
||||||
### Helper properties ###
|
### Properties ###
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending(self):
|
def pending(self):
|
||||||
return self.state in [FollowStates.unrequested, FollowStates.local_requested]
|
return self.state in [FollowStates.unrequested, FollowStates.local_requested]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self):
|
||||||
|
return self.state in FollowStates.group_active()
|
||||||
|
|
||||||
### ActivityPub (outbound) ###
|
### ActivityPub (outbound) ###
|
||||||
|
|
||||||
def to_ap(self):
|
def to_ap(self):
|
||||||
|
@ -226,13 +260,21 @@ class Follow(StatorModel):
|
||||||
### ActivityPub (inbound) ###
|
### ActivityPub (inbound) ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def by_ap(cls, data, create=False) -> "Follow":
|
def by_ap(cls, data: str | dict, create=False) -> "Follow":
|
||||||
"""
|
"""
|
||||||
Retrieves a Follow instance by its ActivityPub JSON object.
|
Retrieves a Follow instance by its ActivityPub JSON object or its URI.
|
||||||
|
|
||||||
Optionally creates one if it's not present.
|
Optionally creates one if it's not present.
|
||||||
Raises KeyError if it's not found and create is False.
|
Raises DoesNotExist if it's not found and create is False.
|
||||||
"""
|
"""
|
||||||
|
# If it's a string, do the reference resolve
|
||||||
|
if isinstance(data, str):
|
||||||
|
bits = data.strip("/").split("/")
|
||||||
|
if bits[-2] != "follow":
|
||||||
|
raise ValueError(f"Unknown Follow object URI: {data}")
|
||||||
|
return Follow.objects.get(pk=bits[-1])
|
||||||
|
# Otherwise, do object resolve
|
||||||
|
else:
|
||||||
# Resolve source and target and see if a Follow exists
|
# Resolve source and target and see if a Follow exists
|
||||||
source = Identity.by_actor_uri(data["actor"], create=create)
|
source = Identity.by_actor_uri(data["actor"], create=create)
|
||||||
target = Identity.by_actor_uri(get_str_or_id(data["object"]))
|
target = Identity.by_actor_uri(get_str_or_id(data["object"]))
|
||||||
|
@ -247,7 +289,7 @@ class Follow(StatorModel):
|
||||||
state=FollowStates.remote_requested,
|
state=FollowStates.remote_requested,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise KeyError(
|
raise cls.DoesNotExist(
|
||||||
f"No follow with source {source} and target {target}", data
|
f"No follow with source {source} and target {target}", data
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
@ -272,14 +314,14 @@ class Follow(StatorModel):
|
||||||
"""
|
"""
|
||||||
Handles an incoming Follow Accept for one of our follows
|
Handles an incoming Follow Accept for one of our follows
|
||||||
"""
|
"""
|
||||||
# Ensure the Accept actor is the Follow's object
|
|
||||||
if data["actor"] != data["object"]["object"]:
|
|
||||||
raise ValueError("Accept actor does not match its Follow object", data)
|
|
||||||
# Resolve source and target and see if a Follow exists (it really should)
|
# Resolve source and target and see if a Follow exists (it really should)
|
||||||
try:
|
try:
|
||||||
follow = cls.by_ap(data["object"])
|
follow = cls.by_ap(data["object"])
|
||||||
except KeyError:
|
except cls.DoesNotExist:
|
||||||
raise ValueError("No Follow locally for incoming Accept", data)
|
raise ValueError("No Follow locally for incoming Accept", data)
|
||||||
|
# Ensure the Accept actor is the Follow's target
|
||||||
|
if data["actor"] != follow.target.actor_uri:
|
||||||
|
raise ValueError("Accept actor does not match its Follow object", data)
|
||||||
# If the follow was waiting to be accepted, transition it
|
# If the follow was waiting to be accepted, transition it
|
||||||
if follow and follow.state in [
|
if follow and follow.state in [
|
||||||
FollowStates.unrequested,
|
FollowStates.unrequested,
|
||||||
|
@ -288,55 +330,33 @@ class Follow(StatorModel):
|
||||||
follow.transition_perform(FollowStates.accepted)
|
follow.transition_perform(FollowStates.accepted)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def handle_accept_ref_ap(cls, data):
|
def handle_reject_ap(cls, data):
|
||||||
"""
|
"""
|
||||||
Handles an incoming Follow Accept for one of our follows where there is
|
Handles an incoming Follow Reject for one of our follows
|
||||||
only an object URI reference.
|
|
||||||
"""
|
"""
|
||||||
# Ensure the object ref is in a format we expect
|
# Resolve source and target and see if a Follow exists (it really should)
|
||||||
bits = data["object"].strip("/").split("/")
|
try:
|
||||||
if bits[-2] != "follow":
|
follow = cls.by_ap(data["object"])
|
||||||
raise ValueError(f"Unknown Follow object URI in Accept: {data['object']}")
|
except cls.DoesNotExist:
|
||||||
# Retrieve the object by PK
|
raise ValueError("No Follow locally for incoming Reject", data)
|
||||||
follow = cls.objects.get(pk=bits[-1])
|
# Ensure the Accept actor is the Follow's target
|
||||||
# Ensure it's from the right actor
|
|
||||||
if data["actor"] != follow.target.actor_uri:
|
if data["actor"] != follow.target.actor_uri:
|
||||||
raise ValueError("Accept actor does not match its Follow object", data)
|
raise ValueError("Reject actor does not match its Follow object", data)
|
||||||
# If the follow was waiting to be accepted, transition it
|
# Mark the follow rejected
|
||||||
if follow.state in [
|
follow.transition_perform(FollowStates.rejected)
|
||||||
FollowStates.unrequested,
|
|
||||||
FollowStates.local_requested,
|
|
||||||
]:
|
|
||||||
follow.transition_perform(FollowStates.accepted)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def handle_undo_ap(cls, data):
|
def handle_undo_ap(cls, data):
|
||||||
"""
|
"""
|
||||||
Handles an incoming Follow Undo for one of our follows
|
Handles an incoming Follow Undo for one of our follows
|
||||||
"""
|
"""
|
||||||
# Ensure the Undo actor is the Follow's actor
|
|
||||||
if data["actor"] != data["object"]["actor"]:
|
|
||||||
raise ValueError("Undo actor does not match its Follow object", data)
|
|
||||||
# Resolve source and target and see if a Follow exists (it hopefully does)
|
# Resolve source and target and see if a Follow exists (it hopefully does)
|
||||||
try:
|
try:
|
||||||
follow = cls.by_ap(data["object"])
|
follow = cls.by_ap(data["object"])
|
||||||
except KeyError:
|
except cls.DoesNotExist:
|
||||||
raise ValueError("No Follow locally for incoming Undo", data)
|
raise ValueError("No Follow locally for incoming Undo", data)
|
||||||
|
# Ensure the Undo actor is the Follow's source
|
||||||
|
if data["actor"] != follow.source.actor_uri:
|
||||||
|
raise ValueError("Accept actor does not match its Follow object", data)
|
||||||
# Delete the follow
|
# Delete the follow
|
||||||
follow.delete()
|
follow.delete()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def handle_reject_ap(cls, data):
|
|
||||||
"""
|
|
||||||
Handles an incoming Follow Reject for one of our follows
|
|
||||||
"""
|
|
||||||
# Ensure the Accept actor is the Follow's object
|
|
||||||
if data["actor"] != data["object"]["object"]:
|
|
||||||
raise ValueError("Accept actor does not match its Follow object", data)
|
|
||||||
# Resolve source and target and see if a Follow exists (it really should)
|
|
||||||
try:
|
|
||||||
follow = cls.by_ap(data["object"])
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError("No Follow locally for incoming Reject", data)
|
|
||||||
# Mark the follow rejected
|
|
||||||
follow.transition_perform(FollowStates.rejected)
|
|
||||||
|
|
|
@ -15,11 +15,13 @@ class InboxMessageStates(StateGraph):
|
||||||
@classmethod
|
@classmethod
|
||||||
async def handle_received(cls, instance: "InboxMessage"):
|
async def handle_received(cls, instance: "InboxMessage"):
|
||||||
from activities.models import Post, PostInteraction
|
from activities.models import Post, PostInteraction
|
||||||
from users.models import Follow, Identity, Report
|
from users.models import Block, Follow, Identity, Report
|
||||||
|
|
||||||
match instance.message_type:
|
match instance.message_type:
|
||||||
case "follow":
|
case "follow":
|
||||||
await sync_to_async(Follow.handle_request_ap)(instance.message)
|
await sync_to_async(Follow.handle_request_ap)(instance.message)
|
||||||
|
case "block":
|
||||||
|
await sync_to_async(Block.handle_ap)(instance.message)
|
||||||
case "announce":
|
case "announce":
|
||||||
await sync_to_async(PostInteraction.handle_ap)(instance.message)
|
await sync_to_async(PostInteraction.handle_ap)(instance.message)
|
||||||
case "like":
|
case "like":
|
||||||
|
@ -65,9 +67,8 @@ class InboxMessageStates(StateGraph):
|
||||||
case "follow":
|
case "follow":
|
||||||
await sync_to_async(Follow.handle_accept_ap)(instance.message)
|
await sync_to_async(Follow.handle_accept_ap)(instance.message)
|
||||||
case None:
|
case None:
|
||||||
await sync_to_async(Follow.handle_accept_ref_ap)(
|
# It's a string object, but these will only be for Follows
|
||||||
instance.message
|
await sync_to_async(Follow.handle_accept_ap)(instance.message)
|
||||||
)
|
|
||||||
case unknown:
|
case unknown:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Cannot handle activity of type accept.{unknown}"
|
f"Cannot handle activity of type accept.{unknown}"
|
||||||
|
@ -76,6 +77,9 @@ class InboxMessageStates(StateGraph):
|
||||||
match instance.message_object_type:
|
match instance.message_object_type:
|
||||||
case "follow":
|
case "follow":
|
||||||
await sync_to_async(Follow.handle_reject_ap)(instance.message)
|
await sync_to_async(Follow.handle_reject_ap)(instance.message)
|
||||||
|
case None:
|
||||||
|
# It's a string object, but these will only be for Follows
|
||||||
|
await sync_to_async(Follow.handle_reject_ap)(instance.message)
|
||||||
case unknown:
|
case unknown:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Cannot handle activity of type reject.{unknown}"
|
f"Cannot handle activity of type reject.{unknown}"
|
||||||
|
@ -84,6 +88,8 @@ class InboxMessageStates(StateGraph):
|
||||||
match instance.message_object_type:
|
match instance.message_object_type:
|
||||||
case "follow":
|
case "follow":
|
||||||
await sync_to_async(Follow.handle_undo_ap)(instance.message)
|
await sync_to_async(Follow.handle_undo_ap)(instance.message)
|
||||||
|
case "block":
|
||||||
|
await sync_to_async(Block.handle_undo_ap)(instance.message)
|
||||||
case "like":
|
case "like":
|
||||||
await sync_to_async(PostInteraction.handle_undo_ap)(
|
await sync_to_async(PostInteraction.handle_undo_ap)(
|
||||||
instance.message
|
instance.message
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.template.defaultfilters import linebreaks_filter
|
from django.template.defaultfilters import linebreaks_filter
|
||||||
|
|
||||||
from core.html import strip_html
|
from core.html import strip_html
|
||||||
from users.models import Follow, FollowStates, Identity
|
from users.models import Block, BlockStates, Follow, FollowStates, Identity
|
||||||
|
|
||||||
|
|
||||||
class IdentityService:
|
class IdentityService:
|
||||||
|
@ -36,16 +34,7 @@ class IdentityService:
|
||||||
Follows a user (or does nothing if already followed).
|
Follows a user (or does nothing if already followed).
|
||||||
Returns the follow.
|
Returns the follow.
|
||||||
"""
|
"""
|
||||||
existing_follow = Follow.maybe_get(from_identity, self.identity)
|
|
||||||
if not existing_follow:
|
|
||||||
return Follow.create_local(from_identity, self.identity, boosts=boosts)
|
return Follow.create_local(from_identity, self.identity, boosts=boosts)
|
||||||
elif existing_follow.state not in FollowStates.group_active():
|
|
||||||
existing_follow.transition_perform(FollowStates.unrequested)
|
|
||||||
|
|
||||||
if existing_follow.boosts != boosts:
|
|
||||||
existing_follow.boosts = boosts
|
|
||||||
existing_follow.save()
|
|
||||||
return cast(Follow, existing_follow)
|
|
||||||
|
|
||||||
def unfollow_from(self, from_identity: Identity):
|
def unfollow_from(self, from_identity: Identity):
|
||||||
"""
|
"""
|
||||||
|
@ -55,34 +44,95 @@ class IdentityService:
|
||||||
if existing_follow:
|
if existing_follow:
|
||||||
existing_follow.transition_perform(FollowStates.undone)
|
existing_follow.transition_perform(FollowStates.undone)
|
||||||
|
|
||||||
|
def block_from(self, from_identity: Identity) -> Block:
|
||||||
|
"""
|
||||||
|
Blocks a user.
|
||||||
|
"""
|
||||||
|
self.unfollow_from(from_identity)
|
||||||
|
return Block.create_local_block(from_identity, self.identity)
|
||||||
|
|
||||||
|
def unblock_from(self, from_identity: Identity):
|
||||||
|
"""
|
||||||
|
Unlocks a user
|
||||||
|
"""
|
||||||
|
existing_block = Block.maybe_get(from_identity, self.identity, mute=False)
|
||||||
|
if existing_block and existing_block.active:
|
||||||
|
existing_block.transition_perform(BlockStates.undone)
|
||||||
|
|
||||||
|
def mute_from(
|
||||||
|
self,
|
||||||
|
from_identity: Identity,
|
||||||
|
duration: int = 0,
|
||||||
|
include_notifications: bool = False,
|
||||||
|
) -> Block:
|
||||||
|
"""
|
||||||
|
Mutes a user.
|
||||||
|
"""
|
||||||
|
return Block.create_local_mute(
|
||||||
|
from_identity,
|
||||||
|
self.identity,
|
||||||
|
duration=duration or None,
|
||||||
|
include_notifications=include_notifications,
|
||||||
|
)
|
||||||
|
|
||||||
|
def unmute_from(self, from_identity: Identity):
|
||||||
|
"""
|
||||||
|
Unmutes a user
|
||||||
|
"""
|
||||||
|
existing_block = Block.maybe_get(from_identity, self.identity, mute=True)
|
||||||
|
if existing_block and existing_block.active:
|
||||||
|
existing_block.transition_perform(BlockStates.undone)
|
||||||
|
|
||||||
|
def relationships(self, from_identity: Identity):
|
||||||
|
"""
|
||||||
|
Returns a dict of any active relationships from the given identity.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"outbound_follow": Follow.maybe_get(
|
||||||
|
from_identity, self.identity, require_active=True
|
||||||
|
),
|
||||||
|
"inbound_follow": Follow.maybe_get(
|
||||||
|
self.identity, from_identity, require_active=True
|
||||||
|
),
|
||||||
|
"outbound_block": Block.maybe_get(
|
||||||
|
from_identity, self.identity, mute=False, require_active=True
|
||||||
|
),
|
||||||
|
"inbound_block": Block.maybe_get(
|
||||||
|
self.identity, from_identity, mute=False, require_active=True
|
||||||
|
),
|
||||||
|
"outbound_mute": Block.maybe_get(
|
||||||
|
from_identity, self.identity, mute=True, require_active=True
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def mastodon_json_relationship(self, from_identity: Identity):
|
def mastodon_json_relationship(self, from_identity: Identity):
|
||||||
"""
|
"""
|
||||||
Returns a Relationship object for the from_identity's relationship
|
Returns a Relationship object for the from_identity's relationship
|
||||||
with this identity.
|
with this identity.
|
||||||
"""
|
"""
|
||||||
|
relationships = self.relationships(from_identity)
|
||||||
follow = self.identity.inbound_follows.filter(
|
|
||||||
source=from_identity,
|
|
||||||
state__in=FollowStates.group_active(),
|
|
||||||
).first()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": self.identity.pk,
|
"id": self.identity.pk,
|
||||||
"following": follow is not None,
|
"following": relationships["outbound_follow"] is not None,
|
||||||
"followed_by": self.identity.outbound_follows.filter(
|
"followed_by": relationships["inbound_follow"] is not None,
|
||||||
target=from_identity,
|
"showing_reblogs": (
|
||||||
state__in=FollowStates.group_active(),
|
relationships["outbound_follow"]
|
||||||
).exists(),
|
and relationships["outbound_follow"].boosts
|
||||||
"showing_reblogs": follow and follow.boosts or False,
|
or False
|
||||||
|
),
|
||||||
"notifying": False,
|
"notifying": False,
|
||||||
"blocking": False,
|
"blocking": relationships["outbound_block"] is not None,
|
||||||
"blocked_by": False,
|
"blocked_by": relationships["inbound_block"] is not None,
|
||||||
"muting": False,
|
"muting": relationships["outbound_mute"] is not None,
|
||||||
"muting_notifications": False,
|
"muting_notifications": False,
|
||||||
"requested": False,
|
"requested": False,
|
||||||
"domain_blocking": False,
|
"domain_blocking": False,
|
||||||
"endorsed": False,
|
"endorsed": False,
|
||||||
"note": (follow and follow.note) or "",
|
"note": (
|
||||||
|
relationships["outbound_follow"]
|
||||||
|
and relationships["outbound_follow"].note
|
||||||
|
or ""
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def set_summary(self, summary: str):
|
def set_summary(self, summary: str):
|
||||||
|
|
|
@ -18,7 +18,7 @@ from core.decorators import cache_page, cache_page_by_ap_json
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
from core.models import Config
|
from core.models import Config
|
||||||
from users.decorators import identity_required
|
from users.decorators import identity_required
|
||||||
from users.models import Domain, Follow, FollowStates, Identity, IdentityStates
|
from users.models import Domain, FollowStates, Identity, IdentityStates
|
||||||
from users.services import IdentityService
|
from users.services import IdentityService
|
||||||
from users.shortcuts import by_handle_or_404
|
from users.shortcuts import by_handle_or_404
|
||||||
|
|
||||||
|
@ -70,8 +70,6 @@ class ViewIdentity(ListView):
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
context["identity"] = self.identity
|
context["identity"] = self.identity
|
||||||
context["follow"] = None
|
|
||||||
context["reverse_follow"] = None
|
|
||||||
context["interactions"] = PostInteraction.get_post_interactions(
|
context["interactions"] = PostInteraction.get_post_interactions(
|
||||||
context["page_obj"],
|
context["page_obj"],
|
||||||
self.request.identity,
|
self.request.identity,
|
||||||
|
@ -85,12 +83,9 @@ class ViewIdentity(ListView):
|
||||||
state__in=FollowStates.group_active()
|
state__in=FollowStates.group_active()
|
||||||
).count()
|
).count()
|
||||||
if self.request.identity:
|
if self.request.identity:
|
||||||
follow = Follow.maybe_get(self.request.identity, self.identity)
|
context.update(
|
||||||
if follow and follow.state in FollowStates.group_active():
|
IdentityService(self.identity).relationships(self.request.identity)
|
||||||
context["follow"] = follow
|
)
|
||||||
reverse_follow = Follow.maybe_get(self.identity, self.request.identity)
|
|
||||||
if reverse_follow and reverse_follow.state in FollowStates.group_active():
|
|
||||||
context["reverse_follow"] = reverse_follow
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -257,6 +252,14 @@ class ActionIdentity(View):
|
||||||
IdentityService(identity).follow_from(self.request.identity)
|
IdentityService(identity).follow_from(self.request.identity)
|
||||||
elif action == "unfollow":
|
elif action == "unfollow":
|
||||||
IdentityService(identity).unfollow_from(self.request.identity)
|
IdentityService(identity).unfollow_from(self.request.identity)
|
||||||
|
elif action == "block":
|
||||||
|
IdentityService(identity).block_from(self.request.identity)
|
||||||
|
elif action == "unblock":
|
||||||
|
IdentityService(identity).unblock_from(self.request.identity)
|
||||||
|
elif action == "mute":
|
||||||
|
IdentityService(identity).mute_from(self.request.identity)
|
||||||
|
elif action == "unmute":
|
||||||
|
IdentityService(identity).unmute_from(self.request.identity)
|
||||||
elif action == "hide_boosts":
|
elif action == "hide_boosts":
|
||||||
IdentityService(identity).follow_from(self.request.identity, boosts=False)
|
IdentityService(identity).follow_from(self.request.identity, boosts=False)
|
||||||
elif action == "show_boosts":
|
elif action == "show_boosts":
|
||||||
|
|
Loading…
Reference in New Issue