Get outbound likes/boosts and their undos working

pull/3/head
Andrew Godwin 2022-11-15 18:30:30 -07:00
rodzic 4aa92744ae
commit 20e63023bb
26 zmienionych plików z 460 dodań i 101 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

File diff suppressed because one or more lines are too long

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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