Posting and fan-out both working

This commit is contained in:
Andrew Godwin 2022-11-11 23:04:43 -07:00
parent feb5d9b74f
commit 8fd5a9292c
11 changed files with 295 additions and 17 deletions

View file

@ -1,11 +1,11 @@
from django.contrib import admin from django.contrib import admin
from activities.models import Post, TimelineEvent from activities.models import FanOut, Post, TimelineEvent
@admin.register(Post) @admin.register(Post)
class PostAdmin(admin.ModelAdmin): class PostAdmin(admin.ModelAdmin):
list_display = ["id", "author", "created"] list_display = ["id", "state", "author", "created"]
raw_id_fields = ["to", "mentions"] raw_id_fields = ["to", "mentions"]
@ -13,3 +13,9 @@ class PostAdmin(admin.ModelAdmin):
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"]
@admin.register(FanOut)
class FanOutAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "type", "identity"]
raw_id_fields = ["identity", "subject_post"]

View file

@ -28,7 +28,7 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("state_ready", models.BooleanField(default=False)), ("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)), ("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)), ("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)), ("state_locked_until", models.DateTimeField(blank=True, null=True)),

View file

@ -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,
},
),
]

View file

@ -1,2 +1,3 @@
from .fan_out import FanOut # noqa
from .post import Post # noqa from .post import Post # noqa
from .timeline_event import TimelineEvent # noqa from .timeline_event import TimelineEvent # noqa

View file

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

View file

@ -1,8 +1,13 @@
from typing import Dict
import urlman import urlman
from django.db import models 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 activities.models.timeline_event import TimelineEvent
from core.html import sanitize_post from core.html import sanitize_post
from core.ld import format_date
from stator.models import State, StateField, StateGraph, StatorModel from stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import Follow from users.models.follow import Follow
from users.models.identity import Identity from users.models.identity import Identity
@ -19,7 +24,8 @@ class PostStates(StateGraph):
""" """
Creates all needed fan-out objects for a new Post. Creates all needed fan-out objects for a new Post.
""" """
pass await instance.afan_out()
return cls.fanned_out
class Post(StatorModel): class Post(StatorModel):
@ -84,11 +90,21 @@ class Post(StatorModel):
blank=True, 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) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
class urls(urlman.Urls): 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): def __str__(self):
return f"{self.author} #{self.id}" return f"{self.author} #{self.id}"
@ -97,6 +113,16 @@ class Post(StatorModel):
def safe_content(self): def safe_content(self):
return sanitize_post(self.content) 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 ### ### Local creation ###
@classmethod @classmethod
@ -111,9 +137,57 @@ class Post(StatorModel):
post.save() post.save()
return post 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 @classmethod
def by_ap(cls, data, create=False) -> "Post": def by_ap(cls, data, create=False) -> "Post":

View file

@ -1,3 +1,4 @@
import datetime
import urllib.parse as urllib_parse import urllib.parse as urllib_parse
from typing import Dict, List, Union 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={}): def builtin_document_loader(url: str, options={}):
# Get URL without scheme # Get URL without scheme
@ -324,3 +327,7 @@ def canonicalise(json_data: Dict, include_security: bool = False) -> Dict:
json_data["@context"] = context json_data["@context"] = context
return jsonld.compact(jsonld.expand(json_data), context) return jsonld.compact(jsonld.expand(json_data), context)
def format_date(value: datetime.datetime) -> str:
return value.strftime(DATETIME_FORMAT)

View file

@ -45,8 +45,8 @@ class StatorModel(models.Model):
concrete model yourself. concrete model yourself.
""" """
# If this row is up for transition attempts # If this row is up for transition attempts (which it always is on creation!)
state_ready = models.BooleanField(default=False) state_ready = models.BooleanField(default=True)
# When the state last actually changed, or the date of instance creation # When the state last actually changed, or the date of instance creation
state_changed = models.DateTimeField(auto_now_add=True) state_changed = models.DateTimeField(auto_now_add=True)

View file

@ -8,7 +8,9 @@
{% endif %} {% endif %}
<h3 class="author"> <h3 class="author">
<a href="{{ post.author.urls.view }}">
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small> {{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
</a>
</h3> </h3>
<time> <time>
<a href="{{ post.urls.view }}">{{ post.created | timesince }} ago</a> <a href="{{ post.urls.view }}">{{ post.created | timesince }} ago</a>

View file

@ -92,7 +92,7 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("state_ready", models.BooleanField(default=False)), ("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)), ("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)), ("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)), ("state_locked_until", models.DateTimeField(blank=True, null=True)),
@ -158,7 +158,7 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("state_ready", models.BooleanField(default=False)), ("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)), ("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)), ("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)), ("state_locked_until", models.DateTimeField(blank=True, null=True)),
@ -230,7 +230,9 @@ class Migration(migrations.Migration):
( (
"users", "users",
models.ManyToManyField( models.ManyToManyField(
related_name="identities", to=settings.AUTH_USER_MODEL blank=True,
related_name="identities",
to=settings.AUTH_USER_MODEL,
), ),
), ),
], ],
@ -286,7 +288,7 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("state_ready", models.BooleanField(default=False)), ("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)), ("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)), ("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)), ("state_locked_until", models.DateTimeField(blank=True, null=True)),

View file

@ -36,12 +36,12 @@ class ViewIdentity(TemplateView):
local=False, local=False,
fetch=True, fetch=True,
) )
statuses = identity.statuses.all()[:100] posts = identity.posts.all()[:100]
if identity.data_age > settings.IDENTITY_MAX_AGE: if identity.data_age > settings.IDENTITY_MAX_AGE:
identity.transition_perform(IdentityStates.outdated) identity.transition_perform(IdentityStates.outdated)
return { return {
"identity": identity, "identity": identity,
"statuses": statuses, "posts": posts,
"follow": Follow.maybe_get(self.request.identity, identity) "follow": Follow.maybe_get(self.request.identity, identity)
if self.request.identity if self.request.identity
else None, else None,
@ -232,9 +232,11 @@ class Inbox(View):
if not identity.verify_signature( if not identity.verify_signature(
signature_details["signature"], headers_string signature_details["signature"], headers_string
): ):
print("Bad signature!")
print(document)
return HttpResponseUnauthorized("Bad signature") return HttpResponseUnauthorized("Bad signature")
# Hand off the item to be processed by the queue # Hand off the item to be processed by the queue
InboxMessage.objects.create(message=document, state_ready=True) InboxMessage.objects.create(message=document)
return HttpResponse(status=202) return HttpResponse(status=202)