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 - [ ] 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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