From 8fd5a9292c7d3aac352d3c0e96288bff8a79cb47 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Fri, 11 Nov 2022 23:04:43 -0700 Subject: [PATCH] Posting and fan-out both working --- activities/admin.py | 10 ++- activities/migrations/0001_initial.py | 2 +- activities/migrations/0002_fan_out.py | 103 ++++++++++++++++++++++++++ activities/models/__init__.py | 1 + activities/models/fan_out.py | 81 ++++++++++++++++++++ activities/models/post.py | 82 +++++++++++++++++++- core/ld.py | 7 ++ stator/models.py | 4 +- templates/activities/_post.html | 4 +- users/migrations/0001_initial.py | 10 ++- users/views/identity.py | 8 +- 11 files changed, 295 insertions(+), 17 deletions(-) create mode 100644 activities/migrations/0002_fan_out.py create mode 100644 activities/models/fan_out.py diff --git a/activities/admin.py b/activities/admin.py index 8b27951..2dec3bf 100644 --- a/activities/admin.py +++ b/activities/admin.py @@ -1,11 +1,11 @@ from django.contrib import admin -from activities.models import Post, TimelineEvent +from activities.models import FanOut, Post, TimelineEvent @admin.register(Post) class PostAdmin(admin.ModelAdmin): - list_display = ["id", "author", "created"] + list_display = ["id", "state", "author", "created"] raw_id_fields = ["to", "mentions"] @@ -13,3 +13,9 @@ class PostAdmin(admin.ModelAdmin): class TimelineEventAdmin(admin.ModelAdmin): list_display = ["id", "identity", "created", "type"] raw_id_fields = ["identity", "subject_post", "subject_identity"] + + +@admin.register(FanOut) +class FanOutAdmin(admin.ModelAdmin): + list_display = ["id", "state", "state_attempted", "type", "identity"] + raw_id_fields = ["identity", "subject_post"] diff --git a/activities/migrations/0001_initial.py b/activities/migrations/0001_initial.py index a97146d..0b350ef 100644 --- a/activities/migrations/0001_initial.py +++ b/activities/migrations/0001_initial.py @@ -28,7 +28,7 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("state_ready", models.BooleanField(default=False)), + ("state_ready", models.BooleanField(default=True)), ("state_changed", models.DateTimeField(auto_now_add=True)), ("state_attempted", models.DateTimeField(blank=True, null=True)), ("state_locked_until", models.DateTimeField(blank=True, null=True)), diff --git a/activities/migrations/0002_fan_out.py b/activities/migrations/0002_fan_out.py new file mode 100644 index 0000000..f3b626e --- /dev/null +++ b/activities/migrations/0002_fan_out.py @@ -0,0 +1,103 @@ +# Generated by Django 4.1.3 on 2022-11-12 05:36 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + +import activities.models.fan_out +import stator.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0001_initial"), + ("activities", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="authored", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name="post", + name="author", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="posts", + to="users.identity", + ), + ), + migrations.AlterField( + model_name="post", + name="mentions", + field=models.ManyToManyField( + blank=True, related_name="posts_mentioning", to="users.identity" + ), + ), + migrations.AlterField( + model_name="post", + name="to", + field=models.ManyToManyField( + blank=True, related_name="posts_to", to="users.identity" + ), + ), + migrations.CreateModel( + name="FanOut", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=True)), + ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_attempted", models.DateTimeField(blank=True, null=True)), + ("state_locked_until", models.DateTimeField(blank=True, null=True)), + ( + "state", + stator.models.StateField( + choices=[("new", "new"), ("sent", "sent")], + default="new", + graph=activities.models.fan_out.FanOutStates, + max_length=100, + ), + ), + ( + "type", + models.CharField( + choices=[("post", "Post"), ("boost", "Boost")], max_length=100 + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fan_outs", + to="users.identity", + ), + ), + ( + "subject_post", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="fan_outs", + to="activities.post", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/activities/models/__init__.py b/activities/models/__init__.py index b74075f..b0ed474 100644 --- a/activities/models/__init__.py +++ b/activities/models/__init__.py @@ -1,2 +1,3 @@ +from .fan_out import FanOut # noqa from .post import Post # noqa from .timeline_event import TimelineEvent # noqa diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py new file mode 100644 index 0000000..79b7409 --- /dev/null +++ b/activities/models/fan_out.py @@ -0,0 +1,81 @@ +from asgiref.sync import sync_to_async +from django.db import models + +from activities.models.timeline_event import TimelineEvent +from core.ld import canonicalise +from core.signatures import HttpSignature +from stator.models import State, StateField, StateGraph, StatorModel + + +class FanOutStates(StateGraph): + new = State(try_interval=300) + sent = State() + + new.transitions_to(sent) + + @classmethod + async def handle_new(cls, instance: "FanOut"): + """ + Sends the fan-out to the right inbox. + """ + fan_out = await instance.afetch_full() + if fan_out.identity.local: + # Make a timeline event directly + await sync_to_async(TimelineEvent.add_post)( + identity=fan_out.identity, + post=fan_out.subject_post, + ) + else: + # Send it to the remote inbox + post = await fan_out.subject_post.afetch_full() + # Sign it and send it + await HttpSignature.signed_request( + uri=fan_out.identity.inbox_uri, + body=canonicalise(post.to_create_ap()), + identity=post.author, + ) + return cls.sent + + +class FanOut(StatorModel): + """ + An activity that needs to get to an inbox somewhere. + """ + + class Types(models.TextChoices): + post = "post" + boost = "boost" + + state = StateField(FanOutStates) + + # The user this event is targeted at + identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="fan_outs", + ) + + # What type of activity it is + type = models.CharField(max_length=100, choices=Types.choices) + + # Links to the appropriate objects + subject_post = models.ForeignKey( + "activities.Post", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="fan_outs", + ) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + ### Async helpers ### + + async def afetch_full(self): + """ + Returns a version of the object with all relations pre-loaded + """ + return await FanOut.objects.select_related("identity", "subject_post").aget( + pk=self.pk + ) diff --git a/activities/models/post.py b/activities/models/post.py index f4d159f..4c40033 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -1,8 +1,13 @@ +from typing import Dict + import urlman from django.db import models +from django.utils import timezone +from activities.models.fan_out import FanOut from activities.models.timeline_event import TimelineEvent from core.html import sanitize_post +from core.ld import format_date from stator.models import State, StateField, StateGraph, StatorModel from users.models.follow import Follow from users.models.identity import Identity @@ -19,7 +24,8 @@ class PostStates(StateGraph): """ Creates all needed fan-out objects for a new Post. """ - pass + await instance.afan_out() + return cls.fanned_out class Post(StatorModel): @@ -84,11 +90,21 @@ class Post(StatorModel): blank=True, ) + # When the post was originally created (as opposed to when we received it) + authored = models.DateTimeField(default=timezone.now) + created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) class urls(urlman.Urls): - view = "{self.identity.urls.view}posts/{self.id}/" + view = "{self.author.urls.view}posts/{self.id}/" + object_uri = "{self.author.urls.actor}posts/{self.id}/" + + def get_scheme(self, url): + return "https" + + def get_hostname(self, url): + return self.instance.author.domain.uri_domain def __str__(self): return f"{self.author} #{self.id}" @@ -97,6 +113,16 @@ class Post(StatorModel): def safe_content(self): return sanitize_post(self.content) + ### Async helpers ### + + async def afetch_full(self): + """ + Returns a version of the object with all relations pre-loaded + """ + return await Post.objects.select_related("author", "author__domain").aget( + pk=self.pk + ) + ### Local creation ### @classmethod @@ -111,9 +137,57 @@ class Post(StatorModel): post.save() return post - ### ActivityPub (outgoing) ### + ### ActivityPub (outbound) ### - ### ActivityPub (incoming) ### + async def afan_out(self): + """ + Creates FanOuts for a new post + """ + # Send a copy to all people who follow this user + post = await self.afetch_full() + async for follow in post.author.inbound_follows.all(): + await FanOut.objects.acreate( + identity_id=follow.source_id, + type=FanOut.Types.post, + subject_post=post, + ) + # And one for themselves + await FanOut.objects.acreate( + identity_id=post.author_id, + type=FanOut.Types.post, + subject_post=post, + ) + + def to_ap(self) -> Dict: + """ + Returns the AP JSON for this object + """ + value = { + "type": "Note", + "id": self.object_uri, + "published": format_date(self.created), + "attributedTo": self.author.actor_uri, + "content": self.safe_content, + "to": "as:Public", + "as:sensitive": self.sensitive, + "url": self.urls.view.full(), # type: ignore + } + if self.summary: + value["summary"] = self.summary + return value + + def to_create_ap(self): + """ + Returns the AP JSON to create this object + """ + return { + "type": "Create", + "id": self.object_uri + "#create", + "actor": self.author.actor_uri, + "object": self.to_ap(), + } + + ### ActivityPub (inbound) ### @classmethod def by_ap(cls, data, create=False) -> "Post": diff --git a/core/ld.py b/core/ld.py index 82e2894..8ced9ac 100644 --- a/core/ld.py +++ b/core/ld.py @@ -1,3 +1,4 @@ +import datetime import urllib.parse as urllib_parse from typing import Dict, List, Union @@ -273,6 +274,8 @@ schemas = { }, } +DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + def builtin_document_loader(url: str, options={}): # Get URL without scheme @@ -324,3 +327,7 @@ def canonicalise(json_data: Dict, include_security: bool = False) -> Dict: json_data["@context"] = context return jsonld.compact(jsonld.expand(json_data), context) + + +def format_date(value: datetime.datetime) -> str: + return value.strftime(DATETIME_FORMAT) diff --git a/stator/models.py b/stator/models.py index 98efce6..b2cc681 100644 --- a/stator/models.py +++ b/stator/models.py @@ -45,8 +45,8 @@ class StatorModel(models.Model): concrete model yourself. """ - # If this row is up for transition attempts - state_ready = models.BooleanField(default=False) + # If this row is up for transition attempts (which it always is on creation!) + state_ready = models.BooleanField(default=True) # When the state last actually changed, or the date of instance creation state_changed = models.DateTimeField(auto_now_add=True) diff --git a/templates/activities/_post.html b/templates/activities/_post.html index ffd0032..2ac57f3 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -8,7 +8,9 @@ {% endif %}

- {{ post.author.name_or_handle }} @{{ post.author.handle }} + + {{ post.author.name_or_handle }} @{{ post.author.handle }} +