Get outbound likes/boosts and their undos working
This commit is contained in:
parent
4aa92744ae
commit
20e63023bb
@ -39,14 +39,14 @@ the less sure I am about it.
|
|||||||
- [ ] Set post visibility
|
- [ ] Set post visibility
|
||||||
- [x] Receive posts
|
- [x] Receive posts
|
||||||
- [ ] Handle received post visibility
|
- [ ] Handle received post visibility
|
||||||
- [ ] Receive post deletions
|
- [x] Receive post deletions
|
||||||
- [x] Set content warnings on posts
|
- [x] Set content warnings on posts
|
||||||
- [ ] Show content warnings on posts
|
- [ ] Show content warnings on posts
|
||||||
- [ ] Attach images to posts
|
- [ ] Attach images to posts
|
||||||
- [ ] Receive images on posts
|
- [ ] Receive images on posts
|
||||||
- [ ] Create boosts
|
- [x] Create boosts
|
||||||
- [x] Receive boosts
|
- [x] Receive boosts
|
||||||
- [ ] Create likes
|
- [x] Create likes
|
||||||
- [x] Receive likes
|
- [x] Receive likes
|
||||||
- [x] Create follows
|
- [x] Create follows
|
||||||
- [ ] Undo follows
|
- [ ] Undo follows
|
||||||
|
@ -6,25 +6,42 @@ from activities.models import FanOut, Post, PostInteraction, TimelineEvent
|
|||||||
@admin.register(Post)
|
@admin.register(Post)
|
||||||
class PostAdmin(admin.ModelAdmin):
|
class PostAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "state", "author", "created"]
|
list_display = ["id", "state", "author", "created"]
|
||||||
raw_id_fields = ["to", "mentions"]
|
raw_id_fields = ["to", "mentions", "author"]
|
||||||
actions = ["force_fetch"]
|
actions = ["force_fetch"]
|
||||||
|
readonly_fields = ["created", "updated", "object_json"]
|
||||||
|
|
||||||
@admin.action(description="Force Fetch")
|
@admin.action(description="Force Fetch")
|
||||||
def force_fetch(self, request, queryset):
|
def force_fetch(self, request, queryset):
|
||||||
for instance in queryset:
|
for instance in queryset:
|
||||||
instance.debug_fetch()
|
instance.debug_fetch()
|
||||||
|
|
||||||
|
@admin.display(description="ActivityPub JSON")
|
||||||
|
def object_json(self, instance):
|
||||||
|
return instance.to_ap()
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TimelineEvent)
|
@admin.register(TimelineEvent)
|
||||||
class TimelineEventAdmin(admin.ModelAdmin):
|
class TimelineEventAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "identity", "created", "type"]
|
list_display = ["id", "identity", "created", "type"]
|
||||||
raw_id_fields = ["identity", "subject_post", "subject_identity"]
|
raw_id_fields = [
|
||||||
|
"identity",
|
||||||
|
"subject_post",
|
||||||
|
"subject_identity",
|
||||||
|
"subject_post_interaction",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(FanOut)
|
@admin.register(FanOut)
|
||||||
class FanOutAdmin(admin.ModelAdmin):
|
class FanOutAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "state", "state_attempted", "type", "identity"]
|
list_display = ["id", "state", "state_attempted", "type", "identity"]
|
||||||
raw_id_fields = ["identity", "subject_post"]
|
raw_id_fields = ["identity", "subject_post", "subject_post_interaction"]
|
||||||
|
readonly_fields = ["created", "updated"]
|
||||||
|
actions = ["force_execution"]
|
||||||
|
|
||||||
|
@admin.action(description="Force Execution")
|
||||||
|
def force_execution(self, request, queryset):
|
||||||
|
for instance in queryset:
|
||||||
|
instance.transition_perform("new")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PostInteraction)
|
@admin.register(PostInteraction)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from .fan_out import FanOut # noqa
|
from .fan_out import FanOut, FanOutStates # noqa
|
||||||
from .post import Post # noqa
|
from .post import Post, PostStates # noqa
|
||||||
from .post_interaction import PostInteraction # noqa
|
from .post_interaction import PostInteraction, PostInteractionStates # noqa
|
||||||
from .timeline_event import TimelineEvent # noqa
|
from .timeline_event import TimelineEvent # noqa
|
||||||
|
@ -38,6 +38,40 @@ class FanOutStates(StateGraph):
|
|||||||
key_id=post.author.public_key_id,
|
key_id=post.author.public_key_id,
|
||||||
)
|
)
|
||||||
return cls.sent
|
return cls.sent
|
||||||
|
# Handle boosts/likes
|
||||||
|
elif fan_out.type == FanOut.Types.interaction:
|
||||||
|
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||||
|
if fan_out.identity.local:
|
||||||
|
# Make a timeline event directly
|
||||||
|
await sync_to_async(TimelineEvent.add_post_interaction)(
|
||||||
|
identity=fan_out.identity,
|
||||||
|
interaction=interaction,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Send it to the remote inbox
|
||||||
|
await HttpSignature.signed_request(
|
||||||
|
uri=fan_out.identity.inbox_uri,
|
||||||
|
body=canonicalise(interaction.to_ap()),
|
||||||
|
private_key=interaction.identity.private_key,
|
||||||
|
key_id=interaction.identity.public_key_id,
|
||||||
|
)
|
||||||
|
# Handle undoing boosts/likes
|
||||||
|
elif fan_out.type == FanOut.Types.undo_interaction:
|
||||||
|
interaction = await fan_out.subject_post_interaction.afetch_full()
|
||||||
|
if fan_out.identity.local:
|
||||||
|
# Delete any local timeline events
|
||||||
|
await sync_to_async(TimelineEvent.delete_post_interaction)(
|
||||||
|
identity=fan_out.identity,
|
||||||
|
interaction=interaction,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Send an undo to the remote inbox
|
||||||
|
await HttpSignature.signed_request(
|
||||||
|
uri=fan_out.identity.inbox_uri,
|
||||||
|
body=canonicalise(interaction.to_undo_ap()),
|
||||||
|
private_key=interaction.identity.private_key,
|
||||||
|
key_id=interaction.identity.public_key_id,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Cannot fan out with type {fan_out.type}")
|
raise ValueError(f"Cannot fan out with type {fan_out.type}")
|
||||||
|
|
||||||
@ -50,6 +84,7 @@ class FanOut(StatorModel):
|
|||||||
class Types(models.TextChoices):
|
class Types(models.TextChoices):
|
||||||
post = "post"
|
post = "post"
|
||||||
interaction = "interaction"
|
interaction = "interaction"
|
||||||
|
undo_interaction = "undo_interaction"
|
||||||
|
|
||||||
state = StateField(FanOutStates)
|
state = StateField(FanOutStates)
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import urlman
|
import urlman
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from activities.models.fan_out import FanOut
|
from activities.models.fan_out import FanOut
|
||||||
@ -99,7 +99,12 @@ class Post(StatorModel):
|
|||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
view = "{self.author.urls.view}posts/{self.id}/"
|
view = "{self.author.urls.view}posts/{self.id}/"
|
||||||
object_uri = "{self.author.urls.actor}posts/{self.id}/"
|
view_nice = "{self.author.urls.view_nice}posts/{self.id}/"
|
||||||
|
object_uri = "{self.author.actor_uri}posts/{self.id}/"
|
||||||
|
action_like = "{view}like/"
|
||||||
|
action_unlike = "{view}unlike/"
|
||||||
|
action_boost = "{view}boost/"
|
||||||
|
action_unboost = "{view}unboost/"
|
||||||
|
|
||||||
def get_scheme(self, url):
|
def get_scheme(self, url):
|
||||||
return "https"
|
return "https"
|
||||||
@ -130,6 +135,7 @@ class Post(StatorModel):
|
|||||||
def create_local(
|
def create_local(
|
||||||
cls, author: Identity, content: str, summary: Optional[str] = None
|
cls, author: Identity, content: str, summary: Optional[str] = None
|
||||||
) -> "Post":
|
) -> "Post":
|
||||||
|
with transaction.atomic():
|
||||||
post = cls.objects.create(
|
post = cls.objects.create(
|
||||||
author=author,
|
author=author,
|
||||||
content=content,
|
content=content,
|
||||||
@ -137,8 +143,8 @@ class Post(StatorModel):
|
|||||||
sensitive=bool(summary),
|
sensitive=bool(summary),
|
||||||
local=True,
|
local=True,
|
||||||
)
|
)
|
||||||
post.object_uri = post.author.actor_uri + f"posts/{post.id}/"
|
post.object_uri = post.urls.object_uri
|
||||||
post.url = post.object_uri
|
post.url = post.urls.view_nice
|
||||||
post.save()
|
post.save()
|
||||||
return post
|
return post
|
||||||
|
|
||||||
@ -179,7 +185,7 @@ class Post(StatorModel):
|
|||||||
"content": self.safe_content,
|
"content": self.safe_content,
|
||||||
"to": "as:Public",
|
"to": "as:Public",
|
||||||
"as:sensitive": self.sensitive,
|
"as:sensitive": self.sensitive,
|
||||||
"url": self.urls.view.full(), # type: ignore
|
"url": self.urls.view_nice if self.local else self.url,
|
||||||
}
|
}
|
||||||
if self.summary:
|
if self.summary:
|
||||||
value["summary"] = self.summary
|
value["summary"] = self.summary
|
||||||
@ -257,7 +263,7 @@ class Post(StatorModel):
|
|||||||
create=True,
|
create=True,
|
||||||
update=True,
|
update=True,
|
||||||
)
|
)
|
||||||
raise ValueError(f"Cannot find Post with URI {object_uri}")
|
raise cls.DoesNotExist(f"Cannot find Post with URI {object_uri}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def handle_create_ap(cls, data):
|
def handle_create_ap(cls, data):
|
||||||
@ -275,6 +281,22 @@ class Post(StatorModel):
|
|||||||
# Force it into fanned_out as it's not ours
|
# Force it into fanned_out as it's not ours
|
||||||
post.transition_perform(PostStates.fanned_out)
|
post.transition_perform(PostStates.fanned_out)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def handle_delete_ap(cls, data):
|
||||||
|
"""
|
||||||
|
Handles an incoming create request
|
||||||
|
"""
|
||||||
|
# Find our post by ID if we have one
|
||||||
|
try:
|
||||||
|
post = cls.by_object_uri(data["object"]["id"])
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
# It's already been deleted
|
||||||
|
return
|
||||||
|
# Ensure the actor on the request authored the post
|
||||||
|
if not post.author.actor_uri == data["actor"]:
|
||||||
|
raise ValueError("Actor on delete does not match object")
|
||||||
|
post.delete()
|
||||||
|
|
||||||
def debug_fetch(self):
|
def debug_fetch(self):
|
||||||
"""
|
"""
|
||||||
Fetches the Post from its original URL again and updates us with it
|
Fetches the Post from its original URL again and updates us with it
|
||||||
|
@ -14,9 +14,13 @@ from users.models.identity import Identity
|
|||||||
|
|
||||||
class PostInteractionStates(StateGraph):
|
class PostInteractionStates(StateGraph):
|
||||||
new = State(try_interval=300)
|
new = State(try_interval=300)
|
||||||
fanned_out = State()
|
fanned_out = State(externally_progressed=True)
|
||||||
|
undone = State(try_interval=300)
|
||||||
|
undone_fanned_out = State()
|
||||||
|
|
||||||
new.transitions_to(fanned_out)
|
new.transitions_to(fanned_out)
|
||||||
|
fanned_out.transitions_to(undone)
|
||||||
|
undone.transitions_to(undone_fanned_out)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def handle_new(cls, instance: "PostInteraction"):
|
async def handle_new(cls, instance: "PostInteraction"):
|
||||||
@ -31,26 +35,74 @@ class PostInteractionStates(StateGraph):
|
|||||||
):
|
):
|
||||||
if follow.source.local or follow.target.local:
|
if follow.source.local or follow.target.local:
|
||||||
await FanOut.objects.acreate(
|
await FanOut.objects.acreate(
|
||||||
identity_id=follow.source_id,
|
|
||||||
type=FanOut.Types.interaction,
|
type=FanOut.Types.interaction,
|
||||||
subject_post=interaction,
|
identity_id=follow.source_id,
|
||||||
|
subject_post=interaction.post,
|
||||||
|
subject_post_interaction=interaction,
|
||||||
)
|
)
|
||||||
# Like: send a copy to the original post author only
|
# Like: send a copy to the original post author only
|
||||||
elif interaction.type == interaction.Types.like:
|
elif interaction.type == interaction.Types.like:
|
||||||
await FanOut.objects.acreate(
|
await FanOut.objects.acreate(
|
||||||
identity_id=interaction.post.author_id,
|
|
||||||
type=FanOut.Types.interaction,
|
type=FanOut.Types.interaction,
|
||||||
subject_post=interaction,
|
identity_id=interaction.post.author_id,
|
||||||
|
subject_post=interaction.post,
|
||||||
|
subject_post_interaction=interaction,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Cannot fan out unknown type")
|
raise ValueError("Cannot fan out unknown type")
|
||||||
# And one for themselves if they're local
|
# And one for themselves if they're local and it's a boost
|
||||||
if interaction.identity.local:
|
if (
|
||||||
|
interaction.type == PostInteraction.Types.boost
|
||||||
|
and interaction.identity.local
|
||||||
|
):
|
||||||
await FanOut.objects.acreate(
|
await FanOut.objects.acreate(
|
||||||
identity_id=interaction.identity_id,
|
identity_id=interaction.identity_id,
|
||||||
type=FanOut.Types.interaction,
|
type=FanOut.Types.interaction,
|
||||||
subject_post=interaction,
|
subject_post=interaction.post,
|
||||||
|
subject_post_interaction=interaction,
|
||||||
)
|
)
|
||||||
|
return cls.fanned_out
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handle_undone(cls, instance: "PostInteraction"):
|
||||||
|
"""
|
||||||
|
Creates all needed fan-out objects to undo a PostInteraction.
|
||||||
|
"""
|
||||||
|
interaction = await instance.afetch_full()
|
||||||
|
# Undo Boost: send a copy to all people who follow this user
|
||||||
|
if interaction.type == interaction.Types.boost:
|
||||||
|
async for follow in interaction.identity.inbound_follows.select_related(
|
||||||
|
"source", "target"
|
||||||
|
):
|
||||||
|
if follow.source.local or follow.target.local:
|
||||||
|
await FanOut.objects.acreate(
|
||||||
|
type=FanOut.Types.undo_interaction,
|
||||||
|
identity_id=follow.source_id,
|
||||||
|
subject_post=interaction.post,
|
||||||
|
subject_post_interaction=interaction,
|
||||||
|
)
|
||||||
|
# Undo Like: send a copy to the original post author only
|
||||||
|
elif interaction.type == interaction.Types.like:
|
||||||
|
await FanOut.objects.acreate(
|
||||||
|
type=FanOut.Types.undo_interaction,
|
||||||
|
identity_id=interaction.post.author_id,
|
||||||
|
subject_post=interaction.post,
|
||||||
|
subject_post_interaction=interaction,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("Cannot fan out unknown type")
|
||||||
|
# And one for themselves if they're local and it's a boost
|
||||||
|
if (
|
||||||
|
interaction.type == PostInteraction.Types.boost
|
||||||
|
and interaction.identity.local
|
||||||
|
):
|
||||||
|
await FanOut.objects.acreate(
|
||||||
|
identity_id=interaction.identity_id,
|
||||||
|
type=FanOut.Types.undo_interaction,
|
||||||
|
subject_post=interaction.post,
|
||||||
|
subject_post_interaction=interaction,
|
||||||
|
)
|
||||||
|
return cls.undone_fanned_out
|
||||||
|
|
||||||
|
|
||||||
class PostInteraction(StatorModel):
|
class PostInteraction(StatorModel):
|
||||||
@ -95,6 +147,35 @@ class PostInteraction(StatorModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
index_together = [["type", "identity", "post"]]
|
index_together = [["type", "identity", "post"]]
|
||||||
|
|
||||||
|
### Display helpers ###
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_post_interactions(cls, posts, identity):
|
||||||
|
"""
|
||||||
|
Returns a dict of {interaction_type: set(post_ids)} for all the posts
|
||||||
|
and the given identity, for use in templates.
|
||||||
|
"""
|
||||||
|
# Bulk-fetch any interactions
|
||||||
|
ids_with_interaction_type = cls.objects.filter(
|
||||||
|
identity=identity,
|
||||||
|
post_id__in=[post.pk for post in posts],
|
||||||
|
type__in=[cls.Types.like, cls.Types.boost],
|
||||||
|
state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out],
|
||||||
|
).values_list("post_id", "type")
|
||||||
|
# Make it into the return dict
|
||||||
|
result = {}
|
||||||
|
for post_id, interaction_type in ids_with_interaction_type:
|
||||||
|
result.setdefault(interaction_type, set()).add(post_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_event_interactions(cls, events, identity):
|
||||||
|
"""
|
||||||
|
Returns a dict of {interaction_type: set(post_ids)} for all the posts
|
||||||
|
within the events and the given identity, for use in templates.
|
||||||
|
"""
|
||||||
|
return cls.get_post_interactions([e.subject_post for e in events], identity)
|
||||||
|
|
||||||
### Async helpers ###
|
### Async helpers ###
|
||||||
|
|
||||||
async def afetch_full(self):
|
async def afetch_full(self):
|
||||||
@ -111,6 +192,9 @@ class PostInteraction(StatorModel):
|
|||||||
"""
|
"""
|
||||||
Returns the AP JSON for this object
|
Returns the AP JSON for this object
|
||||||
"""
|
"""
|
||||||
|
# Create an object URI if we don't have one
|
||||||
|
if self.object_uri is None:
|
||||||
|
self.object_uri = self.identity.actor_uri + f"#{self.type}/{self.id}"
|
||||||
if self.type == self.Types.boost:
|
if self.type == self.Types.boost:
|
||||||
value = {
|
value = {
|
||||||
"type": "Announce",
|
"type": "Announce",
|
||||||
@ -132,6 +216,18 @@ class PostInteraction(StatorModel):
|
|||||||
raise ValueError("Cannot turn into AP")
|
raise ValueError("Cannot turn into AP")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def to_undo_ap(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Returns the AP JSON to undo this object
|
||||||
|
"""
|
||||||
|
object = self.to_ap()
|
||||||
|
return {
|
||||||
|
"id": object["id"] + "/undo",
|
||||||
|
"type": "Undo",
|
||||||
|
"actor": self.identity.actor_uri,
|
||||||
|
"object": object,
|
||||||
|
}
|
||||||
|
|
||||||
### ActivityPub (inbound) ###
|
### ActivityPub (inbound) ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -114,3 +114,20 @@ class TimelineEvent(models.Model):
|
|||||||
subject_identity_id=interaction.identity_id,
|
subject_identity_id=interaction.identity_id,
|
||||||
subject_post_interaction=interaction,
|
subject_post_interaction=interaction,
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_post_interaction(cls, identity, interaction):
|
||||||
|
if interaction.type == interaction.Types.like:
|
||||||
|
cls.objects.filter(
|
||||||
|
identity=identity,
|
||||||
|
type=cls.Types.liked,
|
||||||
|
subject_post_id=interaction.post_id,
|
||||||
|
subject_identity_id=interaction.identity_id,
|
||||||
|
).delete()
|
||||||
|
elif interaction.type == interaction.Types.boost:
|
||||||
|
cls.objects.filter(
|
||||||
|
identity=identity,
|
||||||
|
type__in=[cls.Types.boosted, cls.Types.boost],
|
||||||
|
subject_post_id=interaction.post_id,
|
||||||
|
subject_identity_id=interaction.identity_id,
|
||||||
|
).delete()
|
||||||
|
102
activities/views/posts.py
Normal file
102
activities/views/posts.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.generic import TemplateView, View
|
||||||
|
|
||||||
|
from activities.models import PostInteraction, PostInteractionStates
|
||||||
|
from users.decorators import identity_required
|
||||||
|
from users.shortcuts import by_handle_or_404
|
||||||
|
|
||||||
|
|
||||||
|
class Post(TemplateView):
|
||||||
|
|
||||||
|
template_name = "activities/post.html"
|
||||||
|
|
||||||
|
def get_context_data(self, handle, post_id):
|
||||||
|
identity = by_handle_or_404(self.request, handle, local=False)
|
||||||
|
post = get_object_or_404(identity.posts, pk=post_id)
|
||||||
|
return {
|
||||||
|
"identity": identity,
|
||||||
|
"post": post,
|
||||||
|
"interactions": PostInteraction.get_post_interactions(
|
||||||
|
[post],
|
||||||
|
self.request.identity,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(identity_required, name="dispatch")
|
||||||
|
class Like(View):
|
||||||
|
"""
|
||||||
|
Adds/removes a like from the current identity to the post
|
||||||
|
"""
|
||||||
|
|
||||||
|
undo = False
|
||||||
|
|
||||||
|
def post(self, request, handle, post_id):
|
||||||
|
identity = by_handle_or_404(self.request, handle, local=False)
|
||||||
|
post = get_object_or_404(identity.posts, pk=post_id)
|
||||||
|
if self.undo:
|
||||||
|
# Undo any likes on the post
|
||||||
|
for interaction in PostInteraction.objects.filter(
|
||||||
|
type=PostInteraction.Types.like,
|
||||||
|
identity=request.identity,
|
||||||
|
post=post,
|
||||||
|
):
|
||||||
|
interaction.transition_perform(PostInteractionStates.undone)
|
||||||
|
else:
|
||||||
|
# Make a like on this post if we didn't already
|
||||||
|
PostInteraction.objects.get_or_create(
|
||||||
|
type=PostInteraction.Types.like,
|
||||||
|
identity=request.identity,
|
||||||
|
post=post,
|
||||||
|
)
|
||||||
|
# Return either a redirect or a HTMX snippet
|
||||||
|
if request.htmx:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"activities/_like.html",
|
||||||
|
{
|
||||||
|
"post": post,
|
||||||
|
"interactions": {"like": set() if self.undo else {post.pk}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return redirect(post.urls.view)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(identity_required, name="dispatch")
|
||||||
|
class Boost(View):
|
||||||
|
"""
|
||||||
|
Adds/removes a boost from the current identity to the post
|
||||||
|
"""
|
||||||
|
|
||||||
|
undo = False
|
||||||
|
|
||||||
|
def post(self, request, handle, post_id):
|
||||||
|
identity = by_handle_or_404(self.request, handle, local=False)
|
||||||
|
post = get_object_or_404(identity.posts, pk=post_id)
|
||||||
|
if self.undo:
|
||||||
|
# Undo any boosts on the post
|
||||||
|
for interaction in PostInteraction.objects.filter(
|
||||||
|
type=PostInteraction.Types.boost,
|
||||||
|
identity=request.identity,
|
||||||
|
post=post,
|
||||||
|
):
|
||||||
|
interaction.transition_perform(PostInteractionStates.undone)
|
||||||
|
else:
|
||||||
|
# Make a boost on this post if we didn't already
|
||||||
|
PostInteraction.objects.get_or_create(
|
||||||
|
type=PostInteraction.Types.boost,
|
||||||
|
identity=request.identity,
|
||||||
|
post=post,
|
||||||
|
)
|
||||||
|
# Return either a redirect or a HTMX snippet
|
||||||
|
if request.htmx:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"activities/_boost.html",
|
||||||
|
{
|
||||||
|
"post": post,
|
||||||
|
"interactions": {"boost": set() if self.undo else {post.pk}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return redirect(post.urls.view)
|
@ -4,7 +4,7 @@ from django.template.defaultfilters import linebreaks_filter
|
|||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.generic import FormView, TemplateView
|
from django.views.generic import FormView, TemplateView
|
||||||
|
|
||||||
from activities.models import Post, TimelineEvent
|
from activities.models import Post, PostInteraction, TimelineEvent
|
||||||
from users.decorators import identity_required
|
from users.decorators import identity_required
|
||||||
|
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ class Home(FormView):
|
|||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
context["events"] = (
|
context["events"] = list(
|
||||||
TimelineEvent.objects.filter(
|
TimelineEvent.objects.filter(
|
||||||
identity=self.request.identity,
|
identity=self.request.identity,
|
||||||
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
|
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
|
||||||
@ -41,7 +41,9 @@ class Home(FormView):
|
|||||||
.select_related("subject_post", "subject_post__author")
|
.select_related("subject_post", "subject_post__author")
|
||||||
.order_by("-created")[:100]
|
.order_by("-created")[:100]
|
||||||
)
|
)
|
||||||
|
context["interactions"] = PostInteraction.get_event_interactions(
|
||||||
|
context["events"], self.request.identity
|
||||||
|
)
|
||||||
context["current_page"] = "home"
|
context["current_page"] = "home"
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@ -115,15 +115,11 @@ class HttpSignature:
|
|||||||
if "HTTP_DIGEST" in request.META:
|
if "HTTP_DIGEST" in request.META:
|
||||||
expected_digest = HttpSignature.calculate_digest(request.body)
|
expected_digest = HttpSignature.calculate_digest(request.body)
|
||||||
if request.META["HTTP_DIGEST"] != expected_digest:
|
if request.META["HTTP_DIGEST"] != expected_digest:
|
||||||
print("Wrong digest")
|
|
||||||
raise VerificationFormatError("Digest is incorrect")
|
raise VerificationFormatError("Digest is incorrect")
|
||||||
# Verify date header
|
# Verify date header
|
||||||
if "HTTP_DATE" in request.META and not skip_date:
|
if "HTTP_DATE" in request.META and not skip_date:
|
||||||
header_date = parse_http_date(request.META["HTTP_DATE"])
|
header_date = parse_http_date(request.META["HTTP_DATE"])
|
||||||
if abs(timezone.now().timestamp() - header_date) > 60:
|
if abs(timezone.now().timestamp() - header_date) > 60:
|
||||||
print(
|
|
||||||
f"Date mismatch - they sent {header_date}, now is {timezone.now().timestamp()}"
|
|
||||||
)
|
|
||||||
raise VerificationFormatError("Date is too far away")
|
raise VerificationFormatError("Date is too far away")
|
||||||
# Get the signature details
|
# Get the signature details
|
||||||
if "HTTP_SIGNATURE" not in request.META:
|
if "HTTP_SIGNATURE" not in request.META:
|
||||||
@ -186,7 +182,6 @@ class HttpSignature:
|
|||||||
)
|
)
|
||||||
del headers["(request-target)"]
|
del headers["(request-target)"]
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
print(f"Calling {method} {uri}")
|
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
method,
|
method,
|
||||||
uri,
|
uri,
|
||||||
|
@ -10,3 +10,4 @@ gunicorn~=20.1.0
|
|||||||
psycopg2~=2.9.5
|
psycopg2~=2.9.5
|
||||||
bleach~=5.0.1
|
bleach~=5.0.1
|
||||||
pydantic~=1.10.2
|
pydantic~=1.10.2
|
||||||
|
django-htmx~=1.13.0
|
||||||
|
@ -528,6 +528,23 @@ h1.identity small {
|
|||||||
margin: 12px 0 4px 0;
|
margin: 12px 0 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post .actions {
|
||||||
|
padding-left: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post .actions a {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-dull);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post .actions a:hover {
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post .actions a.active {
|
||||||
|
color: var(--color-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
.boost-banner {
|
.boost-banner {
|
||||||
padding: 0 0 3px 5px;
|
padding: 0 0 3px 5px;
|
||||||
}
|
}
|
||||||
|
1
static/js/htmx.min.js
vendored
Executable file
1
static/js/htmx.min.js
vendored
Executable file
File diff suppressed because one or more lines are too long
@ -11,6 +11,7 @@ class StateGraph:
|
|||||||
choices: ClassVar[List[Tuple[object, str]]]
|
choices: ClassVar[List[Tuple[object, str]]]
|
||||||
initial_state: ClassVar["State"]
|
initial_state: ClassVar["State"]
|
||||||
terminal_states: ClassVar[Set["State"]]
|
terminal_states: ClassVar[Set["State"]]
|
||||||
|
automatic_states: ClassVar[Set["State"]]
|
||||||
|
|
||||||
def __init_subclass__(cls) -> None:
|
def __init_subclass__(cls) -> None:
|
||||||
# Collect state memebers
|
# Collect state memebers
|
||||||
@ -30,6 +31,7 @@ class StateGraph:
|
|||||||
)
|
)
|
||||||
# Check the graph layout
|
# Check the graph layout
|
||||||
terminal_states = set()
|
terminal_states = set()
|
||||||
|
automatic_states = set()
|
||||||
initial_state = None
|
initial_state = None
|
||||||
for state in cls.states.values():
|
for state in cls.states.values():
|
||||||
# Check for multiple initial states
|
# Check for multiple initial states
|
||||||
@ -65,10 +67,12 @@ class StateGraph:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"State '{state}' does not have a handler method ({state.handler_name})"
|
f"State '{state}' does not have a handler method ({state.handler_name})"
|
||||||
)
|
)
|
||||||
|
automatic_states.add(state)
|
||||||
if initial_state is None:
|
if initial_state is None:
|
||||||
raise ValueError("The graph has no initial state")
|
raise ValueError("The graph has no initial state")
|
||||||
cls.initial_state = initial_state
|
cls.initial_state = initial_state
|
||||||
cls.terminal_states = terminal_states
|
cls.terminal_states = terminal_states
|
||||||
|
cls.automatic_states = automatic_states
|
||||||
# Generate choices
|
# Generate choices
|
||||||
cls.choices = [(name, name) for name in cls.states.keys()]
|
cls.choices = [(name, name) for name in cls.states.keys()]
|
||||||
|
|
||||||
|
@ -105,9 +105,11 @@ class StatorModel(models.Model):
|
|||||||
"""
|
"""
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
selected = list(
|
selected = list(
|
||||||
cls.objects.filter(state_locked_until__isnull=True, state_ready=True)[
|
cls.objects.filter(
|
||||||
:number
|
state_locked_until__isnull=True,
|
||||||
].select_for_update()
|
state_ready=True,
|
||||||
|
state__in=cls.state_graph.automatic_states,
|
||||||
|
)[:number].select_for_update()
|
||||||
)
|
)
|
||||||
cls.objects.filter(pk__in=[i.pk for i in selected]).update(
|
cls.objects.filter(pk__in=[i.pk for i in selected]).update(
|
||||||
state_locked_until=lock_expiry
|
state_locked_until=lock_expiry
|
||||||
@ -144,7 +146,9 @@ class StatorModel(models.Model):
|
|||||||
# If it's a manual progression state don't even try
|
# If it's a manual progression state don't even try
|
||||||
# We shouldn't really be here in this case, but it could be a race condition
|
# We shouldn't really be here in this case, but it could be a race condition
|
||||||
if current_state.externally_progressed:
|
if current_state.externally_progressed:
|
||||||
print("Externally progressed state!")
|
print(
|
||||||
|
f"Warning: trying to progress externally progressed state {self.state}!"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
next_state = await current_state.handler(self)
|
next_state = await current_state.handler(self)
|
||||||
@ -183,7 +187,7 @@ class StatorModel(models.Model):
|
|||||||
state_changed=timezone.now(),
|
state_changed=timezone.now(),
|
||||||
state_attempted=None,
|
state_attempted=None,
|
||||||
state_locked_until=None,
|
state_locked_until=None,
|
||||||
state_ready=False,
|
state_ready=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
atransition_perform = sync_to_async(transition_perform)
|
atransition_perform = sync_to_async(transition_perform)
|
||||||
|
@ -12,6 +12,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"django_htmx",
|
||||||
"core",
|
"core",
|
||||||
"activities",
|
"activities",
|
||||||
"users",
|
"users",
|
||||||
@ -26,6 +27,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"django_htmx.middleware.HtmxMiddleware",
|
||||||
"users.middleware.IdentityMiddleware",
|
"users.middleware.IdentityMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from activities.views import timelines
|
from activities.views import posts, timelines
|
||||||
from core import views as core
|
from core import views as core
|
||||||
from stator import views as stator
|
from stator import views as stator
|
||||||
from users.views import activitypub, auth, identity
|
from users.views import activitypub, auth, identity
|
||||||
@ -12,14 +12,20 @@ urlpatterns = [
|
|||||||
path("notifications/", timelines.Notifications.as_view()),
|
path("notifications/", timelines.Notifications.as_view()),
|
||||||
path("local/", timelines.Local.as_view()),
|
path("local/", timelines.Local.as_view()),
|
||||||
path("federated/", timelines.Federated.as_view()),
|
path("federated/", timelines.Federated.as_view()),
|
||||||
# Authentication
|
|
||||||
path("auth/login/", auth.Login.as_view()),
|
|
||||||
path("auth/logout/", auth.Logout.as_view()),
|
|
||||||
# Identity views
|
# Identity views
|
||||||
path("@<handle>/", identity.ViewIdentity.as_view()),
|
path("@<handle>/", identity.ViewIdentity.as_view()),
|
||||||
path("@<handle>/actor/", activitypub.Actor.as_view()),
|
path("@<handle>/actor/", activitypub.Actor.as_view()),
|
||||||
path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
|
path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
|
||||||
path("@<handle>/action/", identity.ActionIdentity.as_view()),
|
path("@<handle>/action/", identity.ActionIdentity.as_view()),
|
||||||
|
# Posts
|
||||||
|
path("@<handle>/posts/<int:post_id>/", posts.Post.as_view()),
|
||||||
|
path("@<handle>/posts/<int:post_id>/like/", posts.Like.as_view()),
|
||||||
|
path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)),
|
||||||
|
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
|
||||||
|
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
|
||||||
|
# Authentication
|
||||||
|
path("auth/login/", auth.Login.as_view()),
|
||||||
|
path("auth/logout/", auth.Logout.as_view()),
|
||||||
# Identity selection
|
# Identity selection
|
||||||
path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
|
path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
|
||||||
path("identity/select/", identity.SelectIdentity.as_view()),
|
path("identity/select/", identity.SelectIdentity.as_view()),
|
||||||
|
@ -1,28 +1,9 @@
|
|||||||
{% load static %}
|
{% if post.pk in interactions.boost %}
|
||||||
{% load activity_tags %}
|
<a title="Unboost" class="active" hx-post="{{ post.urls.action_unboost }}" hx-swap="outerHTML">
|
||||||
<div class="post">
|
<i class="fa-solid fa-retweet"></i>
|
||||||
|
|
||||||
{% if post.author.icon_uri %}
|
|
||||||
<img src="{{post.author.icon_uri}}" class="icon">
|
|
||||||
{% else %}
|
|
||||||
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<time>
|
|
||||||
<a href="{{ post.url }}">
|
|
||||||
{% if post.published %}
|
|
||||||
{{ post.published | timedeltashort }}
|
|
||||||
{% else %}
|
|
||||||
{{ post.created | timedeltashort }}
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
</time>
|
{% else %}
|
||||||
|
<a title="Boost" hx-post="{{ post.urls.action_boost }}" hx-swap="outerHTML">
|
||||||
<a href="{{ post.author.urls.view }}" class="handle">
|
<i class="fa-solid fa-retweet"></i>
|
||||||
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
<div class="content">
|
|
||||||
{{ post.safe_content }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
9
templates/activities/_like.html
Normal file
9
templates/activities/_like.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{% if post.pk in interactions.like %}
|
||||||
|
<a title="Unlike" class="active" hx-post="{{ post.urls.action_unlike }}" hx-swap="outerHTML">
|
||||||
|
<i class="fa-solid fa-star"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a title="Like" hx-post="{{ post.urls.action_like }}" hx-swap="outerHTML">
|
||||||
|
<i class="fa-solid fa-star"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
@ -25,4 +25,11 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
{{ post.safe_content }}
|
{{ post.safe_content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if request.identity %}
|
||||||
|
<div class="actions">
|
||||||
|
{% include "activities/_like.html" %}
|
||||||
|
{% include "activities/_boost.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
17
templates/activities/post.html
Normal file
17
templates/activities/post.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Post by {{ post.author.name_or_handle }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav>
|
||||||
|
<a href="." class="selected">Post</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="columns">
|
||||||
|
|
||||||
|
<div class="left-column">
|
||||||
|
{% include "activities/_post.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
@ -9,9 +9,10 @@
|
|||||||
<link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" />
|
<link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" />
|
||||||
<link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" />
|
<link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" />
|
||||||
<script src="{% static "js/hyperscript.min.js" %}"></script>
|
<script src="{% static "js/hyperscript.min.js" %}"></script>
|
||||||
|
<script src="{% static "js/htmx.min.js" %}"></script>
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="{% block body_class %}{% endblock %}">
|
<body class="{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<header>
|
<header>
|
||||||
|
@ -23,12 +23,17 @@ class IdentityAdmin(admin.ModelAdmin):
|
|||||||
list_display = ["id", "handle", "actor_uri", "state", "local"]
|
list_display = ["id", "handle", "actor_uri", "state", "local"]
|
||||||
raw_id_fields = ["users"]
|
raw_id_fields = ["users"]
|
||||||
actions = ["force_update"]
|
actions = ["force_update"]
|
||||||
|
readonly_fields = ["actor_json"]
|
||||||
|
|
||||||
@admin.action(description="Force Update")
|
@admin.action(description="Force Update")
|
||||||
def force_update(self, request, queryset):
|
def force_update(self, request, queryset):
|
||||||
for instance in queryset:
|
for instance in queryset:
|
||||||
instance.transition_perform("outdated")
|
instance.transition_perform("outdated")
|
||||||
|
|
||||||
|
@admin.display(description="ActivityPub JSON")
|
||||||
|
def actor_json(self, instance):
|
||||||
|
return instance.to_ap()
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Follow)
|
@admin.register(Follow)
|
||||||
class FollowAdmin(admin.ModelAdmin):
|
class FollowAdmin(admin.ModelAdmin):
|
||||||
|
@ -102,8 +102,8 @@ class Identity(StatorModel):
|
|||||||
unique_together = [("username", "domain")]
|
unique_together = [("username", "domain")]
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
|
view_nice = "{self._nice_view_url}"
|
||||||
view = "/@{self.username}@{self.domain_id}/"
|
view = "/@{self.username}@{self.domain_id}/"
|
||||||
view_short = "/@{self.username}/"
|
|
||||||
action = "{view}action/"
|
action = "{view}action/"
|
||||||
activate = "{view}activate/"
|
activate = "{view}activate/"
|
||||||
|
|
||||||
@ -118,6 +118,15 @@ class Identity(StatorModel):
|
|||||||
return self.handle
|
return self.handle
|
||||||
return self.actor_uri
|
return self.actor_uri
|
||||||
|
|
||||||
|
def _nice_view_url(self):
|
||||||
|
"""
|
||||||
|
Returns the "nice" user URL if they're local, otherwise our general one
|
||||||
|
"""
|
||||||
|
if self.local:
|
||||||
|
return f"https://{self.domain.uri_domain}/@{self.username}/"
|
||||||
|
else:
|
||||||
|
return f"/@{self.username}@{self.domain_id}/"
|
||||||
|
|
||||||
### Alternate constructors/fetchers ###
|
### Alternate constructors/fetchers ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -182,6 +191,28 @@ class Identity(StatorModel):
|
|||||||
# TODO: Setting
|
# TODO: Setting
|
||||||
return self.data_age > 60 * 24 * 24
|
return self.data_age > 60 * 24 * 24
|
||||||
|
|
||||||
|
### ActivityPub (boutbound) ###
|
||||||
|
|
||||||
|
def to_ap(self):
|
||||||
|
response = {
|
||||||
|
"id": self.actor_uri,
|
||||||
|
"type": "Person",
|
||||||
|
"inbox": self.actor_uri + "inbox/",
|
||||||
|
"preferredUsername": self.username,
|
||||||
|
"publicKey": {
|
||||||
|
"id": self.public_key_id,
|
||||||
|
"owner": self.actor_uri,
|
||||||
|
"publicKeyPem": self.public_key,
|
||||||
|
},
|
||||||
|
"published": self.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"url": self.urls.view_nice,
|
||||||
|
}
|
||||||
|
if self.name:
|
||||||
|
response["name"] = self.name
|
||||||
|
if self.summary:
|
||||||
|
response["summary"] = self.summary
|
||||||
|
return response
|
||||||
|
|
||||||
### Actor/Webfinger fetching ###
|
### Actor/Webfinger fetching ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -46,6 +46,14 @@ class InboxMessageStates(StateGraph):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Cannot handle activity of type undo.{unknown}"
|
f"Cannot handle activity of type undo.{unknown}"
|
||||||
)
|
)
|
||||||
|
case "delete":
|
||||||
|
match instance.message_object_type:
|
||||||
|
case "tombstone":
|
||||||
|
await sync_to_async(Post.handle_delete_ap)(instance.message)
|
||||||
|
case unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot handle activity of type delete.{unknown}"
|
||||||
|
)
|
||||||
case unknown:
|
case unknown:
|
||||||
raise ValueError(f"Cannot handle activity of type {unknown}")
|
raise ValueError(f"Cannot handle activity of type {unknown}")
|
||||||
return cls.processed
|
return cls.processed
|
||||||
|
@ -52,13 +52,13 @@ class Webfinger(View):
|
|||||||
{
|
{
|
||||||
"subject": f"acct:{identity.handle}",
|
"subject": f"acct:{identity.handle}",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
identity.urls.view_short.full(),
|
identity.view_url,
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"rel": "http://webfinger.net/rel/profile-page",
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
"type": "text/html",
|
"type": "text/html",
|
||||||
"href": identity.urls.view_short.full(),
|
"href": identity.view_url,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rel": "self",
|
"rel": "self",
|
||||||
@ -77,28 +77,7 @@ class Actor(View):
|
|||||||
|
|
||||||
def get(self, request, handle):
|
def get(self, request, handle):
|
||||||
identity = by_handle_or_404(self.request, handle)
|
identity = by_handle_or_404(self.request, handle)
|
||||||
response = {
|
return JsonResponse(canonicalise(identity.to_ap(), include_security=True))
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
"https://w3id.org/security/v1",
|
|
||||||
],
|
|
||||||
"id": identity.actor_uri,
|
|
||||||
"type": "Person",
|
|
||||||
"inbox": identity.actor_uri + "inbox/",
|
|
||||||
"preferredUsername": identity.username,
|
|
||||||
"publicKey": {
|
|
||||||
"id": identity.public_key_id,
|
|
||||||
"owner": identity.actor_uri,
|
|
||||||
"publicKeyPem": identity.public_key,
|
|
||||||
},
|
|
||||||
"published": identity.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
||||||
"url": identity.urls.view_short.full(),
|
|
||||||
}
|
|
||||||
if identity.name:
|
|
||||||
response["name"] = identity.name
|
|
||||||
if identity.summary:
|
|
||||||
response["summary"] = identity.summary
|
|
||||||
return JsonResponse(canonicalise(response, include_security=True))
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
Reference in New Issue
Block a user