summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--activities/admin.py10
-rw-r--r--activities/migrations/0001_initial.py2
-rw-r--r--activities/migrations/0002_fan_out.py103
-rw-r--r--activities/models/__init__.py1
-rw-r--r--activities/models/fan_out.py81
-rw-r--r--activities/models/post.py82
-rw-r--r--core/ld.py7
-rw-r--r--stator/models.py4
-rw-r--r--templates/activities/_post.html4
-rw-r--r--users/migrations/0001_initial.py10
-rw-r--r--users/views/identity.py8
11 files changed, 295 insertions, 17 deletions
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 %}
<h3 class="author">
- {{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
+ <a href="{{ post.author.urls.view }}">
+ {{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
+ </a>
</h3>
<time>
<a href="{{ post.urls.view }}">{{ post.created | timesince }} ago</a>
diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py
index c4f4774..a51ef00 100644
--- a/users/migrations/0001_initial.py
+++ b/users/migrations/0001_initial.py
@@ -92,7 +92,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)),
@@ -158,7 +158,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)),
@@ -230,7 +230,9 @@ class Migration(migrations.Migration):
(
"users",
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",
),
),
- ("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/users/views/identity.py b/users/views/identity.py
index 6205145..3bcf5c5 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -36,12 +36,12 @@ class ViewIdentity(TemplateView):
local=False,
fetch=True,
)
- statuses = identity.statuses.all()[:100]
+ posts = identity.posts.all()[:100]
if identity.data_age > settings.IDENTITY_MAX_AGE:
identity.transition_perform(IdentityStates.outdated)
return {
"identity": identity,
- "statuses": statuses,
+ "posts": posts,
"follow": Follow.maybe_get(self.request.identity, identity)
if self.request.identity
else None,
@@ -232,9 +232,11 @@ class Inbox(View):
if not identity.verify_signature(
signature_details["signature"], headers_string
):
+ print("Bad signature!")
+ print(document)
return HttpResponseUnauthorized("Bad signature")
# 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)