diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74f9cbe..27d6551 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: rev: 22.10.0 hooks: - id: black - args: ["--target-version=py37"] + language_version: python3.10 - repo: https://github.com/pycqa/isort rev: 5.10.1 @@ -35,4 +35,4 @@ repos: rev: v0.982 hooks: - id: mypy - additional_dependencies: [types-pyopenssl] + additional_dependencies: [types-pyopenssl, types-bleach] diff --git a/statuses/__init__.py b/activities/__init__.py similarity index 100% rename from statuses/__init__.py rename to activities/__init__.py diff --git a/activities/admin.py b/activities/admin.py new file mode 100644 index 0000000..8b27951 --- /dev/null +++ b/activities/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from activities.models import Post, TimelineEvent + + +@admin.register(Post) +class PostAdmin(admin.ModelAdmin): + list_display = ["id", "author", "created"] + raw_id_fields = ["to", "mentions"] + + +@admin.register(TimelineEvent) +class TimelineEventAdmin(admin.ModelAdmin): + list_display = ["id", "identity", "created", "type"] + raw_id_fields = ["identity", "subject_post", "subject_identity"] diff --git a/statuses/apps.py b/activities/apps.py similarity index 61% rename from statuses/apps.py rename to activities/apps.py index b0a694e..cffeee3 100644 --- a/statuses/apps.py +++ b/activities/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class StatusesConfig(AppConfig): +class ActivitiesConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "statuses" + name = "activities" diff --git a/activities/migrations/0001_initial.py b/activities/migrations/0001_initial.py new file mode 100644 index 0000000..a97146d --- /dev/null +++ b/activities/migrations/0001_initial.py @@ -0,0 +1,155 @@ +# Generated by Django 4.1.3 on 2022-11-11 20:02 + +import django.db.models.deletion +from django.db import migrations, models + +import activities.models.post +import stator.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Post", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state_ready", models.BooleanField(default=False)), + ("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"), ("fanned_out", "fanned_out")], + default="new", + graph=activities.models.post.PostStates, + max_length=100, + ), + ), + ("local", models.BooleanField()), + ("object_uri", models.CharField(blank=True, max_length=500, null=True)), + ( + "visibility", + models.IntegerField( + choices=[ + (0, "Public"), + (1, "Unlisted"), + (2, "Followers"), + (3, "Mentioned"), + ], + default=0, + ), + ), + ("content", models.TextField()), + ("sensitive", models.BooleanField(default=False)), + ("summary", models.TextField(blank=True, null=True)), + ("url", models.CharField(blank=True, max_length=500, null=True)), + ( + "in_reply_to", + models.CharField(blank=True, max_length=500, null=True), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="statuses", + to="users.identity", + ), + ), + ( + "mentions", + models.ManyToManyField( + related_name="posts_mentioning", to="users.identity" + ), + ), + ( + "to", + models.ManyToManyField( + related_name="posts_to", to="users.identity" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="TimelineEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("post", "Post"), + ("mention", "Mention"), + ("like", "Like"), + ("follow", "Follow"), + ("boost", "Boost"), + ], + max_length=100, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "identity", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="timeline_events", + to="users.identity", + ), + ), + ( + "subject_identity", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="timeline_events_about_us", + to="users.identity", + ), + ), + ( + "subject_post", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="timeline_events_about_us", + to="activities.post", + ), + ), + ], + options={ + "index_together": { + ("identity", "type", "subject_post", "subject_identity"), + ("identity", "type", "subject_identity"), + }, + }, + ), + ] diff --git a/statuses/migrations/__init__.py b/activities/migrations/__init__.py similarity index 100% rename from statuses/migrations/__init__.py rename to activities/migrations/__init__.py diff --git a/activities/models/__init__.py b/activities/models/__init__.py new file mode 100644 index 0000000..b74075f --- /dev/null +++ b/activities/models/__init__.py @@ -0,0 +1,2 @@ +from .post import Post # noqa +from .timeline_event import TimelineEvent # noqa diff --git a/activities/models/post.py b/activities/models/post.py new file mode 100644 index 0000000..f4d159f --- /dev/null +++ b/activities/models/post.py @@ -0,0 +1,161 @@ +import urlman +from django.db import models + +from activities.models.timeline_event import TimelineEvent +from core.html import sanitize_post +from stator.models import State, StateField, StateGraph, StatorModel +from users.models.follow import Follow +from users.models.identity import Identity + + +class PostStates(StateGraph): + new = State(try_interval=300) + fanned_out = State() + + new.transitions_to(fanned_out) + + @classmethod + async def handle_new(cls, instance: "Post"): + """ + Creates all needed fan-out objects for a new Post. + """ + pass + + +class Post(StatorModel): + """ + A post (status, toot) that is either local or remote. + """ + + class Visibilities(models.IntegerChoices): + public = 0 + unlisted = 1 + followers = 2 + mentioned = 3 + + # The author (attributedTo) of the post + author = models.ForeignKey( + "users.Identity", + on_delete=models.PROTECT, + related_name="posts", + ) + + # The state the post is in + state = StateField(PostStates) + + # If it is our post or not + local = models.BooleanField() + + # The canonical object ID + object_uri = models.CharField(max_length=500, blank=True, null=True) + + # Who should be able to see this Post + visibility = models.IntegerField( + choices=Visibilities.choices, + default=Visibilities.public, + ) + + # The main (HTML) content + content = models.TextField() + + # If the contents of the post are sensitive, and the summary (content + # warning) to show if it is + sensitive = models.BooleanField(default=False) + summary = models.TextField(blank=True, null=True) + + # The public, web URL of this Post on the original server + url = models.CharField(max_length=500, blank=True, null=True) + + # The Post it is replying to as an AP ID URI + # (as otherwise we'd have to pull entire threads to use IDs) + in_reply_to = models.CharField(max_length=500, blank=True, null=True) + + # The identities the post is directly to (who can see it if not public) + to = models.ManyToManyField( + "users.Identity", + related_name="posts_to", + blank=True, + ) + + # The identities mentioned in the post + mentions = models.ManyToManyField( + "users.Identity", + related_name="posts_mentioning", + blank=True, + ) + + 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}/" + + def __str__(self): + return f"{self.author} #{self.id}" + + @property + def safe_content(self): + return sanitize_post(self.content) + + ### Local creation ### + + @classmethod + def create_local(cls, author: Identity, content: str) -> "Post": + post = cls.objects.create( + author=author, + content=content, + local=True, + ) + post.object_uri = post.author.actor_uri + f"posts/{post.id}/" + post.url = post.object_uri + post.save() + return post + + ### ActivityPub (outgoing) ### + + ### ActivityPub (incoming) ### + + @classmethod + def by_ap(cls, data, create=False) -> "Post": + """ + Retrieves a Post instance by its ActivityPub JSON object. + + Optionally creates one if it's not present. + Raises KeyError if it's not found and create is False. + """ + # Do we have one with the right ID? + try: + return cls.objects.get(object_uri=data["id"]) + except cls.DoesNotExist: + if create: + # Resolve the author + author = Identity.by_actor_uri(data["attributedTo"], create=create) + return cls.objects.create( + author=author, + content=sanitize_post(data["content"]), + summary=data.get("summary", None), + sensitive=data.get("as:sensitive", False), + url=data.get("url", None), + local=False, + # TODO: to + # TODO: mentions + # TODO: visibility + ) + else: + raise KeyError(f"No post with ID {data['id']}", data) + + @classmethod + def handle_create_ap(cls, data): + """ + Handles an incoming create request + """ + # Ensure the Create actor is the Post's attributedTo + if data["actor"] != data["object"]["attributedTo"]: + raise ValueError("Create actor does not match its Post object", data) + # Create it + post = cls.by_ap(data["object"], create=True) + # Make timeline events as appropriate + for follow in Follow.objects.filter(target=post.author, source__local=True): + TimelineEvent.add_post(follow.source, post) + # Force it into fanned_out as it's not ours + post.transition_perform(PostStates.fanned_out) diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py new file mode 100644 index 0000000..43fc458 --- /dev/null +++ b/activities/models/timeline_event.py @@ -0,0 +1,85 @@ +from django.db import models + + +class TimelineEvent(models.Model): + """ + Something that has happened to an identity that we want them to see on one + or more timelines, like posts, likes and follows. + """ + + class Types(models.TextChoices): + post = "post" + mention = "mention" + like = "like" + follow = "follow" + boost = "boost" + + # The user this event is for + identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="timeline_events", + ) + + # What type of event it is + type = models.CharField(max_length=100, choices=Types.choices) + + # The subject of the event (which is used depends on the type) + subject_post = models.ForeignKey( + "activities.Post", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="timeline_events_about_us", + ) + subject_identity = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="timeline_events_about_us", + ) + + created = models.DateTimeField(auto_now_add=True) + + class Meta: + index_together = [ + # This relies on a DB that can use left subsets of indexes + ("identity", "type", "subject_post", "subject_identity"), + ("identity", "type", "subject_identity"), + ] + + ### Alternate constructors ### + + @classmethod + def add_follow(cls, identity, source_identity): + """ + Adds a follow to the timeline if it's not there already + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.follow, + subject_identity=source_identity, + )[0] + + @classmethod + def add_post(cls, identity, post): + """ + Adds a post to the timeline if it's not there already + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.post, + subject_post=post, + )[0] + + @classmethod + def add_like(cls, identity, post): + """ + Adds a like to the timeline if it's not there already + """ + return cls.objects.get_or_create( + identity=identity, + type=cls.Types.like, + subject_post=post, + )[0] diff --git a/statuses/views/__init__.py b/activities/views/__init__.py similarity index 100% rename from statuses/views/__init__.py rename to activities/views/__init__.py diff --git a/statuses/views/home.py b/activities/views/home.py similarity index 52% rename from statuses/views/home.py rename to activities/views/home.py index b9b98d2..867856d 100644 --- a/statuses/views/home.py +++ b/activities/views/home.py @@ -1,17 +1,18 @@ from django import forms from django.shortcuts import redirect +from django.template.defaultfilters import linebreaks_filter from django.utils.decorators import method_decorator from django.views.generic import FormView +from activities.models import Post, TimelineEvent from core.forms import FormHelper -from statuses.models import Status from users.decorators import identity_required @method_decorator(identity_required, name="dispatch") class Home(FormView): - template_name = "statuses/home.html" + template_name = "activities/home.html" class form_class(forms.Form): text = forms.CharField() @@ -22,14 +23,20 @@ class Home(FormView): context = super().get_context_data() context.update( { - "statuses": self.request.identity.statuses.all()[:100], + "timeline_posts": [ + te.subject_post + for te in TimelineEvent.objects.filter( + identity=self.request.identity, + type=TimelineEvent.Types.post, + ).order_by("-created")[:100] + ], } ) return context def form_valid(self, form): - Status.create_local( - identity=self.request.identity, - text=form.cleaned_data["text"], + Post.create_local( + author=self.request.identity, + content=linebreaks_filter(form.cleaned_data["text"]), ) return redirect(".") diff --git a/core/html.py b/core/html.py new file mode 100644 index 0000000..e63dda3 --- /dev/null +++ b/core/html.py @@ -0,0 +1,11 @@ +import bleach +from django.utils.safestring import mark_safe + + +def sanitize_post(post_html: str) -> str: + """ + Only allows a, br, p and span tags, and class attributes. + """ + return mark_safe( + bleach.clean(post_html, tags=["a", "br", "p", "span"], attributes=["class"]) + ) diff --git a/core/ld.py b/core/ld.py index 2211ba9..82e2894 100644 --- a/core/ld.py +++ b/core/ld.py @@ -1,4 +1,5 @@ import urllib.parse as urllib_parse +from typing import Dict, List, Union from pyld import jsonld from pyld.jsonld import JsonLdError @@ -299,24 +300,27 @@ def builtin_document_loader(url: str, options={}): ) -def canonicalise(json_data, include_security=False): +def canonicalise(json_data: Dict, include_security: bool = False) -> Dict: """ Given an ActivityPub JSON-LD document, round-trips it through the LD systems to end up in a canonicalised, compacted format. + If no context is provided, supplies one automatically. + For most well-structured incoming data this won't actually do anything, but it's probably good to abide by the spec. """ - if not isinstance(json_data, (dict, list)): + if not isinstance(json_data, dict): raise ValueError("Pass decoded JSON data into LDDocument") - return jsonld.compact( - jsonld.expand(json_data), - ( - [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ] - if include_security - else "https://www.w3.org/ns/activitystreams" - ), - ) + context: Union[str, List[str]] + if include_security: + context = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ] + else: + context = "https://www.w3.org/ns/activitystreams" + if "@context" not in json_data: + json_data["@context"] = context + + return jsonld.compact(jsonld.expand(json_data), context) diff --git a/core/views.py b/core/views.py index dbaebf9..205224c 100644 --- a/core/views.py +++ b/core/views.py @@ -1,6 +1,6 @@ from django.views.generic import TemplateView -from statuses.views.home import Home +from activities.views.home import Home from users.models import Identity diff --git a/requirements.txt b/requirements.txt index 53ab2b0..4f7c763 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ httpx~=0.23 pyOpenSSL~=22.1.0 uvicorn~=0.19 gunicorn~=20.1.0 -psycopg2==2.9.5 +psycopg2~=2.9.5 +bleach~=5.0.1 diff --git a/static/css/style.css b/static/css/style.css index 511d301..f5ebc52 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -290,3 +290,41 @@ h1.identity small { .system-note a { color: inherit; } + +/* Posts */ + +.post { + margin-bottom: 20px; + overflow: hidden; +} + +.post .icon { + height: 48px; + width: auto; + float: left; +} + +.post .author { + padding-left: 64px; +} + +.post .author a, +.post time a { + color: inherit; + text-decoration: none; +} + +.post .author small { + font-weight: normal; + color: var(--color-text-dull); +} + +.post time { + display: block; + padding-left: 64px; + color: var(--color-text-duller); +} + +.post .content { + padding-left: 64px; +} diff --git a/stator/models.py b/stator/models.py index 072a3ed..98efce6 100644 --- a/stator/models.py +++ b/stator/models.py @@ -1,4 +1,5 @@ import datetime +import pprint import traceback from typing import ClassVar, List, Optional, Type, Union, cast @@ -218,10 +219,16 @@ class StatorError(models.Model): instance: StatorModel, exception: Optional[BaseException] = None, ): + detail = traceback.format_exc() + if exception and len(exception.args) > 1: + detail += "\n\n" + "\n\n".join( + pprint.pformat(arg) for arg in exception.args + ) + return await cls.objects.acreate( model_label=instance._meta.label_lower, instance_pk=str(instance.pk), state=instance.state, error=str(exception), - error_details=traceback.format_exc(), + error_details=detail, ) diff --git a/statuses/admin.py b/statuses/admin.py deleted file mode 100644 index 040a0eb..0000000 --- a/statuses/admin.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin - -from statuses.models import Status - - -@admin.register(Status) -class StatusAdmin(admin.ModelAdmin): - pass diff --git a/statuses/migrations/0001_initial.py b/statuses/migrations/0001_initial.py deleted file mode 100644 index c4a8fec..0000000 --- a/statuses/migrations/0001_initial.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-10 05:58 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("users", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Status", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("local", models.BooleanField()), - ("uri", models.CharField(blank=True, max_length=500, null=True)), - ( - "visibility", - models.IntegerField( - choices=[ - (0, "Public"), - (1, "Unlisted"), - (2, "Followers"), - (3, "Mentioned"), - ], - default=0, - ), - ), - ("text", models.TextField()), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ("deleted", models.DateTimeField(blank=True, null=True)), - ( - "identity", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="statuses", - to="users.identity", - ), - ), - ], - ), - ] diff --git a/statuses/models/__init__.py b/statuses/models/__init__.py deleted file mode 100644 index 84098eb..0000000 --- a/statuses/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .status import Status # noqa diff --git a/statuses/models/status.py b/statuses/models/status.py deleted file mode 100644 index b12a595..0000000 --- a/statuses/models/status.py +++ /dev/null @@ -1,42 +0,0 @@ -import urlman -from django.db import models - - -class Status(models.Model): - class StatusVisibility(models.IntegerChoices): - public = 0 - unlisted = 1 - followers = 2 - mentioned = 3 - - identity = models.ForeignKey( - "users.Identity", - on_delete=models.PROTECT, - related_name="statuses", - ) - - local = models.BooleanField() - uri = models.CharField(max_length=500, blank=True, null=True) - visibility = models.IntegerField( - choices=StatusVisibility.choices, - default=StatusVisibility.public, - ) - text = models.TextField() - - created = models.DateTimeField(auto_now_add=True) - updated = models.DateTimeField(auto_now=True) - deleted = models.DateTimeField(null=True, blank=True) - - class Meta: - verbose_name_plural = "statuses" - - @classmethod - def create_local(cls, identity, text: str): - return cls.objects.create( - identity=identity, - text=text, - local=True, - ) - - class urls(urlman.Urls): - view = "{self.identity.urls.view}statuses/{self.id}/" diff --git a/takahe/settings.py b/takahe/settings.py index cefbb35..e8982ae 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -24,7 +24,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "crispy_forms", "core", - "statuses", + "activities", "users", "stator", ] diff --git a/templates/activities/_post.html b/templates/activities/_post.html new file mode 100644 index 0000000..ffd0032 --- /dev/null +++ b/templates/activities/_post.html @@ -0,0 +1,19 @@ +{% load static %} +
+ + {% if post.author.icon_uri %} + + {% else %} + + {% endif %} + +

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

+ +
+ {{ post.safe_content }} +
+
diff --git a/templates/statuses/home.html b/templates/activities/home.html similarity index 65% rename from templates/statuses/home.html rename to templates/activities/home.html index e47ccfd..9fe4bba 100644 --- a/templates/statuses/home.html +++ b/templates/activities/home.html @@ -7,9 +7,9 @@ {% crispy form form.helper %} - {% for status in statuses %} - {% include "statuses/_status.html" %} + {% for post in timeline_posts %} + {% include "activities/_post.html" %} {% empty %} - No statuses yet. + No posts yet. {% endfor %} {% endblock %} diff --git a/templates/identity/view.html b/templates/identity/view.html index d82543e..916bee2 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -39,9 +39,9 @@ {% endif %} - {% for status in statuses %} - {% include "statuses/_status.html" %} + {% for post in posts %} + {% include "activities/_post.html" %} {% empty %} - No statuses yet. + No posts yet. {% endfor %} {% endblock %} diff --git a/templates/statuses/_status.html b/templates/statuses/_status.html deleted file mode 100644 index b501abc..0000000 --- a/templates/statuses/_status.html +++ /dev/null @@ -1,12 +0,0 @@ -
-

- - {{ status.identity }} - {{ status.identity.handle }} - -

- - {{ status.text | linebreaks }} -
diff --git a/users/admin.py b/users/admin.py index d8f2931..a4cea27 100644 --- a/users/admin.py +++ b/users/admin.py @@ -21,16 +21,18 @@ class UserEventAdmin(admin.ModelAdmin): @admin.register(Identity) class IdentityAdmin(admin.ModelAdmin): list_display = ["id", "handle", "actor_uri", "state", "local"] + raw_id_fields = ["users"] @admin.register(Follow) class FollowAdmin(admin.ModelAdmin): list_display = ["id", "source", "target", "state"] + raw_id_fields = ["source", "target"] @admin.register(InboxMessage) class InboxMessageAdmin(admin.ModelAdmin): - list_display = ["id", "state", "message_type"] + list_display = ["id", "state", "state_attempted", "message_type"] actions = ["reset_state"] @admin.action(description="Reset State") diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 2f64337..c4f4774 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.3 on 2022-11-10 05:58 +# Generated by Django 4.1.3 on 2022-11-11 20:02 import functools @@ -296,11 +296,14 @@ class Migration(migrations.Migration): "state", stator.models.StateField( choices=[ - ("pending", "pending"), - ("requested", "requested"), + ("unrequested", "unrequested"), + ("local_requested", "local_requested"), + ("remote_requested", "remote_requested"), ("accepted", "accepted"), + ("undone_locally", "undone_locally"), + ("undone_remotely", "undone_remotely"), ], - default="pending", + default="unrequested", graph=users.models.follow.FollowStates, max_length=100, ), diff --git a/users/models/domain.py b/users/models/domain.py index a3815ee..d2b17e2 100644 --- a/users/models/domain.py +++ b/users/models/domain.py @@ -49,10 +49,7 @@ class Domain(models.Model): @classmethod def get_remote_domain(cls, domain: str) -> "Domain": - try: - return cls.objects.get(domain=domain, local=False) - except cls.DoesNotExist: - return cls.objects.create(domain=domain, local=False) + return cls.objects.get_or_create(domain=domain, local=False)[0] @classmethod def get_domain(cls, domain: str) -> Optional["Domain"]: @@ -93,3 +90,4 @@ class Domain(models.Model): raise ValueError( f"Service domain {self.service_domain} is already a domain elsewhere!" ) + super().save(*args, **kwargs) diff --git a/users/models/follow.py b/users/models/follow.py index 94ad40f..81ffcd9 100644 --- a/users/models/follow.py +++ b/users/models/follow.py @@ -5,10 +5,11 @@ from django.db import models from core.ld import canonicalise from core.signatures import HttpSignature from stator.models import State, StateField, StateGraph, StatorModel +from users.models.identity import Identity class FollowStates(StateGraph): - unrequested = State(try_interval=30) + unrequested = State(try_interval=300) local_requested = State(try_interval=24 * 60 * 60) remote_requested = State(try_interval=24 * 60 * 60) accepted = State(externally_progressed=True) @@ -24,26 +25,19 @@ class FollowStates(StateGraph): @classmethod async def handle_unrequested(cls, instance: "Follow"): - # Re-retrieve the follow with more things linked - follow = await Follow.objects.select_related( - "source", "source__domain", "target" - ).aget(pk=instance.pk) + """ + Follows that are unrequested need us to deliver the Follow object + to the target server. + """ + follow = await instance.afetch_full() # Remote follows should not be here if not follow.source.local: return cls.remote_requested - # Construct the request - request = canonicalise( - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": follow.uri, - "type": "Follow", - "actor": follow.source.actor_uri, - "object": follow.target.actor_uri, - } - ) # Sign it and send it await HttpSignature.signed_request( - follow.target.inbox_uri, request, follow.source + uri=follow.target.inbox_uri, + body=canonicalise(follow.to_ap()), + identity=follow.source, ) return cls.local_requested @@ -54,56 +48,28 @@ class FollowStates(StateGraph): @classmethod async def handle_remote_requested(cls, instance: "Follow"): - # Re-retrieve the follow with more things linked - follow = await Follow.objects.select_related( - "source", "source__domain", "target" - ).aget(pk=instance.pk) - # Send an accept - request = canonicalise( - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": follow.target.actor_uri + f"follow/{follow.pk}/#accept", - "type": "Follow", - "actor": follow.source.actor_uri, - "object": { - "id": follow.uri, - "type": "Follow", - "actor": follow.source.actor_uri, - "object": follow.target.actor_uri, - }, - } - ) - # Sign it and send it + """ + Items in remote_requested need us to send an Accept object to the + source server. + """ + follow = await instance.afetch_full() await HttpSignature.signed_request( - follow.source.inbox_uri, - request, + uri=follow.source.inbox_uri, + body=canonicalise(follow.to_accept_ap()), identity=follow.target, ) return cls.accepted @classmethod async def handle_undone_locally(cls, instance: "Follow"): - follow = Follow.objects.select_related( - "source", "source__domain", "target" - ).get(pk=instance.pk) - # Construct the request - request = canonicalise( - { - "@context": "https://www.w3.org/ns/activitystreams", - "id": follow.uri + "#undo", - "type": "Undo", - "actor": follow.source.actor_uri, - "object": { - "id": follow.uri, - "type": "Follow", - "actor": follow.source.actor_uri, - "object": follow.target.actor_uri, - }, - } - ) - # Sign it and send it + """ + Delivers the Undo object to the target server + """ + follow = await instance.afetch_full() await HttpSignature.signed_request( - follow.target.inbox_uri, request, follow.source + uri=follow.target.inbox_uri, + body=canonicalise(follow.to_undo_ap()), + identity=follow.source, ) return cls.undone_remotely @@ -135,6 +101,11 @@ class Follow(StatorModel): class Meta: unique_together = [("source", "target")] + def __str__(self): + return f"#{self.id}: {self.source} → {self.target}" + + ### Alternate fetchers/constructors ### + @classmethod def maybe_get(cls, source, target) -> Optional["Follow"]: """ @@ -164,22 +135,122 @@ class Follow(StatorModel): follow.save() return follow - @classmethod - def remote_created(cls, source, target, uri): - follow = cls.maybe_get(source=source, target=target) - if follow is None: - follow = Follow.objects.create(source=source, target=target, uri=uri) - if follow.state == FollowStates.unrequested: - follow.transition_perform(FollowStates.remote_requested) + ### Async helpers ### + + async def afetch_full(self): + """ + Returns a version of the object with all relations pre-loaded + """ + return await Follow.objects.select_related( + "source", "source__domain", "target" + ).aget(pk=self.pk) + + ### ActivityPub (outbound) ### + + def to_ap(self): + """ + Returns the AP JSON for this object + """ + return { + "type": "Follow", + "id": self.uri, + "actor": self.source.actor_uri, + "object": self.target.actor_uri, + } + + def to_accept_ap(self): + """ + Returns the AP JSON for this objects' accept. + """ + return { + "type": "Accept", + "id": self.uri + "#accept", + "actor": self.target.actor_uri, + "object": self.to_ap(), + } + + def to_undo_ap(self): + """ + Returns the AP JSON for this objects' undo. + """ + return { + "type": "Undo", + "id": self.uri + "#undo", + "actor": self.source.actor_uri, + "object": self.to_ap(), + } + + ### ActivityPub (inbound) ### @classmethod - def remote_accepted(cls, source, target): - print(f"accepted follow source {source} target {target}") + def by_ap(cls, data, create=False) -> "Follow": + """ + Retrieves a Follow instance by its ActivityPub JSON object. + + Optionally creates one if it's not present. + Raises KeyError if it's not found and create is False. + """ + # Resolve source and target and see if a Follow exists + source = Identity.by_actor_uri(data["actor"], create=create) + target = Identity.by_actor_uri(data["object"]) follow = cls.maybe_get(source=source, target=target) - print(f"accepting follow {follow}") + # If it doesn't exist, create one in the remote_requested state + if follow is None: + if create: + return cls.objects.create( + source=source, + target=target, + uri=data["id"], + state=FollowStates.remote_requested, + ) + else: + raise KeyError( + f"No follow with source {source} and target {target}", data + ) + else: + return follow + + @classmethod + def handle_request_ap(cls, data): + """ + Handles an incoming follow request + """ + follow = cls.by_ap(data, create=True) + # Force it into remote_requested so we send an accept + follow.transition_perform(FollowStates.remote_requested) + + @classmethod + def handle_accept_ap(cls, data): + """ + Handles an incoming Follow Accept for one of our follows + """ + # Ensure the Accept actor is the Follow's object + if data["actor"] != data["object"]["object"]: + raise ValueError("Accept actor does not match its Follow object", data) + # Resolve source and target and see if a Follow exists (it really should) + try: + follow = cls.by_ap(data["object"]) + except KeyError: + raise ValueError("No Follow locally for incoming Accept", data) + # If the follow was waiting to be accepted, transition it if follow and follow.state in [ FollowStates.unrequested, FollowStates.local_requested, ]: follow.transition_perform(FollowStates.accepted) - print("accepted") + + @classmethod + def handle_undo_ap(cls, data): + """ + Handles an incoming Follow Undo for one of our follows + """ + # Ensure the Undo actor is the Follow's actor + if data["actor"] != data["object"]["actor"]: + raise ValueError("Undo actor does not match its Follow object", data) + # Resolve source and target and see if a Follow exists (it hopefully does) + try: + follow = cls.by_ap(data["object"]) + except KeyError: + raise ValueError("No Follow locally for incoming Undo", data) + # Delete the follow + follow.delete() diff --git a/users/models/identity.py b/users/models/identity.py index 7dff492..4ec0342 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -55,7 +55,11 @@ class Identity(StatorModel): state = StateField(IdentityStates) local = models.BooleanField() - users = models.ManyToManyField("users.User", related_name="identities") + users = models.ManyToManyField( + "users.User", + related_name="identities", + blank=True, + ) username = models.CharField(max_length=500, blank=True, null=True) # Must be a display domain if present @@ -141,18 +145,14 @@ class Identity(StatorModel): return None @classmethod - def by_actor_uri(cls, uri) -> Optional["Identity"]: + def by_actor_uri(cls, uri, create=False) -> "Identity": try: return cls.objects.get(actor_uri=uri) except cls.DoesNotExist: - return None - - @classmethod - def by_actor_uri_with_create(cls, uri) -> "Identity": - try: - return cls.objects.get(actor_uri=uri) - except cls.DoesNotExist: - return cls.objects.create(actor_uri=uri, local=False) + if create: + return cls.objects.create(actor_uri=uri, local=False) + else: + raise KeyError(f"No identity found matching {uri}") ### Dynamic properties ### @@ -236,7 +236,7 @@ class Identity(StatorModel): self.outbox_uri = document.get("outbox") self.summary = document.get("summary") self.username = document.get("preferredUsername") - if "@value" in self.username: + if self.username and "@value" in self.username: self.username = self.username["@value"] self.manually_approves_followers = document.get( "as:manuallyApprovesFollowers" diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py index 54b05e9..ea55b17 100644 --- a/users/models/inbox_message.py +++ b/users/models/inbox_message.py @@ -2,7 +2,6 @@ from asgiref.sync import sync_to_async from django.db import models from stator.models import State, StateField, StateGraph, StatorModel -from users.models import Follow, Identity class InboxMessageStates(StateGraph): @@ -13,23 +12,38 @@ class InboxMessageStates(StateGraph): @classmethod async def handle_received(cls, instance: "InboxMessage"): - type = instance.message_type - if type == "follow": - await instance.follow_request() - elif type == "accept": - inner_type = instance.message["object"]["type"].lower() - if inner_type == "follow": - await instance.follow_accepted() - else: - raise ValueError(f"Cannot handle activity of type accept.{inner_type}") - elif type == "undo": - inner_type = instance.message["object"]["type"].lower() - if inner_type == "follow": - await instance.follow_undo() - else: - raise ValueError(f"Cannot handle activity of type undo.{inner_type}") - else: - raise ValueError(f"Cannot handle activity of type {type}") + from activities.models import Post + from users.models import Follow + + match instance.message_type: + case "follow": + await sync_to_async(Follow.handle_request_ap)(instance.message) + case "create": + match instance.message_object_type: + case "note": + await sync_to_async(Post.handle_create_ap)(instance.message) + case unknown: + raise ValueError( + f"Cannot handle activity of type create.{unknown}" + ) + case "accept": + match instance.message_object_type: + case "follow": + await sync_to_async(Follow.handle_accept_ap)(instance.message) + case unknown: + raise ValueError( + f"Cannot handle activity of type accept.{unknown}" + ) + case "undo": + match instance.message_object_type: + case "follow": + await sync_to_async(Follow.handle_undo_ap)(instance.message) + case unknown: + raise ValueError( + f"Cannot handle activity of type undo.{unknown}" + ) + case unknown: + raise ValueError(f"Cannot handle activity of type {unknown}") return cls.processed @@ -45,35 +59,10 @@ class InboxMessage(StatorModel): state = StateField(InboxMessageStates) - @sync_to_async - def follow_request(self): - """ - Handles an incoming follow request - """ - Follow.remote_created( - source=Identity.by_actor_uri_with_create(self.message["actor"]), - target=Identity.by_actor_uri(self.message["object"]), - uri=self.message["id"], - ) - - @sync_to_async - def follow_accepted(self): - """ - Handles an incoming acceptance of one of our follow requests - """ - target = Identity.by_actor_uri_with_create(self.message["actor"]) - source = Identity.by_actor_uri(self.message["object"]["actor"]) - if source is None: - raise ValueError( - f"Follow-Accept has invalid source {self.message['object']['actor']}" - ) - Follow.remote_accepted(source=source, target=target) - @property def message_type(self): return self.message["type"].lower() - async def follow_undo(self): - """ - Handles an incoming follow undo - """ + @property + def message_object_type(self): + return self.message["object"]["type"].lower() diff --git a/users/views/identity.py b/users/views/identity.py index 0aed7fa..6205145 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -222,7 +222,7 @@ class Inbox(View): # Find the Identity by the actor on the incoming item # This ensures that the signature used for the headers matches the actor # described in the payload. - identity = Identity.by_actor_uri_with_create(document["actor"]) + identity = Identity.by_actor_uri(document["actor"], create=True) if not identity.public_key: # See if we can fetch it right now async_to_sync(identity.fetch_actor)()