mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-21 23:01:00 +00:00
Get outbound likes/boosts and their undos working
This commit is contained in:
parent
4aa92744ae
commit
20e63023bb
26 changed files with 460 additions and 101 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
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.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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
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]]]
|
||||
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()]
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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 %}
|
||||
|
|
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">
|
||||
{{ post.safe_content }}
|
||||
</div>
|
||||
|
||||
{% if request.identity %}
|
||||
<div class="actions">
|
||||
{% include "activities/_like.html" %}
|
||||
{% include "activities/_boost.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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/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>
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in a new issue