Get outbound likes/boosts and their undos working

This commit is contained in:
Andrew Godwin 2022-11-15 18:30:30 -07:00
parent 4aa92744ae
commit 20e63023bb
26 changed files with 460 additions and 101 deletions

View File

@ -39,14 +39,14 @@ the less sure I am about it.
- [ ] Set post visibility
- [x] Receive posts
- [ ] Handle received post visibility
- [ ] Receive post deletions
- [x] Receive post deletions
- [x] Set content warnings on posts
- [ ] Show content warnings on posts
- [ ] Attach images to posts
- [ ] Receive images on posts
- [ ] Create boosts
- [x] Create boosts
- [x] Receive boosts
- [ ] Create likes
- [x] Create likes
- [x] Receive likes
- [x] Create follows
- [ ] Undo follows

View File

@ -6,25 +6,42 @@ from activities.models import FanOut, Post, PostInteraction, TimelineEvent
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ["id", "state", "author", "created"]
raw_id_fields = ["to", "mentions"]
raw_id_fields = ["to", "mentions", "author"]
actions = ["force_fetch"]
readonly_fields = ["created", "updated", "object_json"]
@admin.action(description="Force Fetch")
def force_fetch(self, request, queryset):
for instance in queryset:
instance.debug_fetch()
@admin.display(description="ActivityPub JSON")
def object_json(self, instance):
return instance.to_ap()
@admin.register(TimelineEvent)
class TimelineEventAdmin(admin.ModelAdmin):
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)
class FanOutAdmin(admin.ModelAdmin):
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)

View File

@ -1,4 +1,4 @@
from .fan_out import FanOut # noqa
from .post import Post # noqa
from .post_interaction import PostInteraction # noqa
from .fan_out import FanOut, FanOutStates # noqa
from .post import Post, PostStates # noqa
from .post_interaction import PostInteraction, PostInteractionStates # noqa
from .timeline_event import TimelineEvent # noqa

View File

@ -38,6 +38,40 @@ class FanOutStates(StateGraph):
key_id=post.author.public_key_id,
)
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:
raise ValueError(f"Cannot fan out with type {fan_out.type}")
@ -50,6 +84,7 @@ class FanOut(StatorModel):
class Types(models.TextChoices):
post = "post"
interaction = "interaction"
undo_interaction = "undo_interaction"
state = StateField(FanOutStates)

View File

@ -2,7 +2,7 @@ from typing import Dict, Optional
import httpx
import urlman
from django.db import models
from django.db import models, transaction
from django.utils import timezone
from activities.models.fan_out import FanOut
@ -99,7 +99,12 @@ class Post(StatorModel):
class urls(urlman.Urls):
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):
return "https"
@ -130,16 +135,17 @@ class Post(StatorModel):
def create_local(
cls, author: Identity, content: str, summary: Optional[str] = None
) -> "Post":
post = cls.objects.create(
author=author,
content=content,
summary=summary or None,
sensitive=bool(summary),
local=True,
)
post.object_uri = post.author.actor_uri + f"posts/{post.id}/"
post.url = post.object_uri
post.save()
with transaction.atomic():
post = cls.objects.create(
author=author,
content=content,
summary=summary or None,
sensitive=bool(summary),
local=True,
)
post.object_uri = post.urls.object_uri
post.url = post.urls.view_nice
post.save()
return post
### ActivityPub (outbound) ###
@ -179,7 +185,7 @@ class Post(StatorModel):
"content": self.safe_content,
"to": "as:Public",
"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:
value["summary"] = self.summary
@ -257,7 +263,7 @@ class Post(StatorModel):
create=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
def handle_create_ap(cls, data):
@ -275,6 +281,22 @@ class Post(StatorModel):
# Force it into fanned_out as it's not ours
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):
"""
Fetches the Post from its original URL again and updates us with it

View File

@ -14,9 +14,13 @@ from users.models.identity import Identity
class PostInteractionStates(StateGraph):
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)
fanned_out.transitions_to(undone)
undone.transitions_to(undone_fanned_out)
@classmethod
async def handle_new(cls, instance: "PostInteraction"):
@ -31,26 +35,74 @@ class PostInteractionStates(StateGraph):
):
if follow.source.local or follow.target.local:
await FanOut.objects.acreate(
identity_id=follow.source_id,
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
elif interaction.type == interaction.Types.like:
await FanOut.objects.acreate(
identity_id=interaction.post.author_id,
type=FanOut.Types.interaction,
subject_post=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
if interaction.identity.local:
# 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.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):
@ -95,6 +147,35 @@ class PostInteraction(StatorModel):
class Meta:
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 def afetch_full(self):
@ -111,6 +192,9 @@ class PostInteraction(StatorModel):
"""
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:
value = {
"type": "Announce",
@ -132,6 +216,18 @@ class PostInteraction(StatorModel):
raise ValueError("Cannot turn into AP")
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) ###
@classmethod

View File

@ -114,3 +114,20 @@ class TimelineEvent(models.Model):
subject_identity_id=interaction.identity_id,
subject_post_interaction=interaction,
)[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
View 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)

View File

@ -4,7 +4,7 @@ from django.template.defaultfilters import linebreaks_filter
from django.utils.decorators import method_decorator
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
@ -33,7 +33,7 @@ class Home(FormView):
def get_context_data(self):
context = super().get_context_data()
context["events"] = (
context["events"] = list(
TimelineEvent.objects.filter(
identity=self.request.identity,
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
@ -41,7 +41,9 @@ class Home(FormView):
.select_related("subject_post", "subject_post__author")
.order_by("-created")[:100]
)
context["interactions"] = PostInteraction.get_event_interactions(
context["events"], self.request.identity
)
context["current_page"] = "home"
return context

View File

@ -115,15 +115,11 @@ class HttpSignature:
if "HTTP_DIGEST" in request.META:
expected_digest = HttpSignature.calculate_digest(request.body)
if request.META["HTTP_DIGEST"] != expected_digest:
print("Wrong digest")
raise VerificationFormatError("Digest is incorrect")
# Verify date header
if "HTTP_DATE" in request.META and not skip_date:
header_date = parse_http_date(request.META["HTTP_DATE"])
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")
# Get the signature details
if "HTTP_SIGNATURE" not in request.META:
@ -186,7 +182,6 @@ class HttpSignature:
)
del headers["(request-target)"]
async with httpx.AsyncClient() as client:
print(f"Calling {method} {uri}")
response = await client.request(
method,
uri,

View File

@ -10,3 +10,4 @@ gunicorn~=20.1.0
psycopg2~=2.9.5
bleach~=5.0.1
pydantic~=1.10.2
django-htmx~=1.13.0

View File

@ -528,6 +528,23 @@ h1.identity small {
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 {
padding: 0 0 3px 5px;
}

1
static/js/htmx.min.js vendored Executable file

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,7 @@ class StateGraph:
choices: ClassVar[List[Tuple[object, str]]]
initial_state: ClassVar["State"]
terminal_states: ClassVar[Set["State"]]
automatic_states: ClassVar[Set["State"]]
def __init_subclass__(cls) -> None:
# Collect state memebers
@ -30,6 +31,7 @@ class StateGraph:
)
# Check the graph layout
terminal_states = set()
automatic_states = set()
initial_state = None
for state in cls.states.values():
# Check for multiple initial states
@ -65,10 +67,12 @@ class StateGraph:
raise ValueError(
f"State '{state}' does not have a handler method ({state.handler_name})"
)
automatic_states.add(state)
if initial_state is None:
raise ValueError("The graph has no initial state")
cls.initial_state = initial_state
cls.terminal_states = terminal_states
cls.automatic_states = automatic_states
# Generate choices
cls.choices = [(name, name) for name in cls.states.keys()]

View File

@ -105,9 +105,11 @@ class StatorModel(models.Model):
"""
with transaction.atomic():
selected = list(
cls.objects.filter(state_locked_until__isnull=True, state_ready=True)[
:number
].select_for_update()
cls.objects.filter(
state_locked_until__isnull=True,
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(
state_locked_until=lock_expiry
@ -144,7 +146,9 @@ class StatorModel(models.Model):
# 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
if current_state.externally_progressed:
print("Externally progressed state!")
print(
f"Warning: trying to progress externally progressed state {self.state}!"
)
return None
try:
next_state = await current_state.handler(self)
@ -183,7 +187,7 @@ class StatorModel(models.Model):
state_changed=timezone.now(),
state_attempted=None,
state_locked_until=None,
state_ready=False,
state_ready=True,
)
atransition_perform = sync_to_async(transition_perform)

View File

@ -12,6 +12,7 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_htmx",
"core",
"activities",
"users",
@ -26,6 +27,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"users.middleware.IdentityMiddleware",
]

View File

@ -1,7 +1,7 @@
from django.contrib import admin
from django.urls import path
from activities.views import timelines
from activities.views import posts, timelines
from core import views as core
from stator import views as stator
from users.views import activitypub, auth, identity
@ -12,14 +12,20 @@ urlpatterns = [
path("notifications/", timelines.Notifications.as_view()),
path("local/", timelines.Local.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
path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/actor/", activitypub.Actor.as_view()),
path("@<handle>/actor/inbox/", activitypub.Inbox.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
path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()),

View File

@ -1,28 +1,9 @@
{% load static %}
{% load activity_tags %}
<div class="post">
{% 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>
</time>
<a href="{{ post.author.urls.view }}" class="handle">
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
{% if post.pk in interactions.boost %}
<a title="Unboost" class="active" hx-post="{{ post.urls.action_unboost }}" hx-swap="outerHTML">
<i class="fa-solid fa-retweet"></i>
</a>
<div class="content">
{{ post.safe_content }}
</div>
</div>
{% else %}
<a title="Boost" hx-post="{{ post.urls.action_boost }}" hx-swap="outerHTML">
<i class="fa-solid fa-retweet"></i>
</a>
{% endif %}

View 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 %}

View File

@ -25,4 +25,11 @@
<div class="content">
{{ post.safe_content }}
</div>
{% if request.identity %}
<div class="actions">
{% include "activities/_like.html" %}
{% include "activities/_boost.html" %}
</div>
{% endif %}
</div>

View 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 %}

View File

@ -9,9 +9,10 @@
<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" />
<script src="{% static "js/hyperscript.min.js" %}"></script>
<script src="{% static "js/htmx.min.js" %}"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<body class="{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<main>
<header>

View File

@ -23,12 +23,17 @@ class IdentityAdmin(admin.ModelAdmin):
list_display = ["id", "handle", "actor_uri", "state", "local"]
raw_id_fields = ["users"]
actions = ["force_update"]
readonly_fields = ["actor_json"]
@admin.action(description="Force Update")
def force_update(self, request, queryset):
for instance in queryset:
instance.transition_perform("outdated")
@admin.display(description="ActivityPub JSON")
def actor_json(self, instance):
return instance.to_ap()
@admin.register(Follow)
class FollowAdmin(admin.ModelAdmin):

View File

@ -102,8 +102,8 @@ class Identity(StatorModel):
unique_together = [("username", "domain")]
class urls(urlman.Urls):
view_nice = "{self._nice_view_url}"
view = "/@{self.username}@{self.domain_id}/"
view_short = "/@{self.username}/"
action = "{view}action/"
activate = "{view}activate/"
@ -118,6 +118,15 @@ class Identity(StatorModel):
return self.handle
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 ###
@classmethod
@ -182,6 +191,28 @@ class Identity(StatorModel):
# TODO: Setting
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 ###
@classmethod

View File

@ -46,6 +46,14 @@ class InboxMessageStates(StateGraph):
raise ValueError(
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:
raise ValueError(f"Cannot handle activity of type {unknown}")
return cls.processed

View File

@ -52,13 +52,13 @@ class Webfinger(View):
{
"subject": f"acct:{identity.handle}",
"aliases": [
identity.urls.view_short.full(),
identity.view_url,
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": identity.urls.view_short.full(),
"href": identity.view_url,
},
{
"rel": "self",
@ -77,28 +77,7 @@ class Actor(View):
def get(self, request, handle):
identity = by_handle_or_404(self.request, handle)
response = {
"@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))
return JsonResponse(canonicalise(identity.to_ap(), include_security=True))
@method_decorator(csrf_exempt, name="dispatch")