mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 08:41:00 +00:00
Got up to incoming posts working
This commit is contained in:
parent
fbfad9fbf5
commit
feb5d9b74f
33 changed files with 737 additions and 288 deletions
|
@ -18,7 +18,7 @@ repos:
|
||||||
rev: 22.10.0
|
rev: 22.10.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
args: ["--target-version=py37"]
|
language_version: python3.10
|
||||||
|
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: 5.10.1
|
rev: 5.10.1
|
||||||
|
@ -35,4 +35,4 @@ repos:
|
||||||
rev: v0.982
|
rev: v0.982
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
additional_dependencies: [types-pyopenssl]
|
additional_dependencies: [types-pyopenssl, types-bleach]
|
||||||
|
|
15
activities/admin.py
Normal file
15
activities/admin.py
Normal file
|
@ -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"]
|
|
@ -1,6 +1,6 @@
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class StatusesConfig(AppConfig):
|
class ActivitiesConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "statuses"
|
name = "activities"
|
155
activities/migrations/0001_initial.py
Normal file
155
activities/migrations/0001_initial.py
Normal file
|
@ -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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
2
activities/models/__init__.py
Normal file
2
activities/models/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from .post import Post # noqa
|
||||||
|
from .timeline_event import TimelineEvent # noqa
|
161
activities/models/post.py
Normal file
161
activities/models/post.py
Normal file
|
@ -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)
|
85
activities/models/timeline_event.py
Normal file
85
activities/models/timeline_event.py
Normal file
|
@ -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]
|
|
@ -1,17 +1,18 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
from django.template.defaultfilters import linebreaks_filter
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
|
|
||||||
|
from activities.models import Post, TimelineEvent
|
||||||
from core.forms import FormHelper
|
from core.forms import FormHelper
|
||||||
from statuses.models import Status
|
|
||||||
from users.decorators import identity_required
|
from users.decorators import identity_required
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(identity_required, name="dispatch")
|
@method_decorator(identity_required, name="dispatch")
|
||||||
class Home(FormView):
|
class Home(FormView):
|
||||||
|
|
||||||
template_name = "statuses/home.html"
|
template_name = "activities/home.html"
|
||||||
|
|
||||||
class form_class(forms.Form):
|
class form_class(forms.Form):
|
||||||
text = forms.CharField()
|
text = forms.CharField()
|
||||||
|
@ -22,14 +23,20 @@ class Home(FormView):
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
context.update(
|
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
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
Status.create_local(
|
Post.create_local(
|
||||||
identity=self.request.identity,
|
author=self.request.identity,
|
||||||
text=form.cleaned_data["text"],
|
content=linebreaks_filter(form.cleaned_data["text"]),
|
||||||
)
|
)
|
||||||
return redirect(".")
|
return redirect(".")
|
11
core/html.py
Normal file
11
core/html.py
Normal file
|
@ -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"])
|
||||||
|
)
|
24
core/ld.py
24
core/ld.py
|
@ -1,4 +1,5 @@
|
||||||
import urllib.parse as urllib_parse
|
import urllib.parse as urllib_parse
|
||||||
|
from typing import Dict, List, Union
|
||||||
|
|
||||||
from pyld import jsonld
|
from pyld import jsonld
|
||||||
from pyld.jsonld import JsonLdError
|
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
|
Given an ActivityPub JSON-LD document, round-trips it through the LD
|
||||||
systems to end up in a canonicalised, compacted format.
|
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,
|
For most well-structured incoming data this won't actually do anything,
|
||||||
but it's probably good to abide by the spec.
|
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")
|
raise ValueError("Pass decoded JSON data into LDDocument")
|
||||||
return jsonld.compact(
|
context: Union[str, List[str]]
|
||||||
jsonld.expand(json_data),
|
if include_security:
|
||||||
(
|
context = [
|
||||||
[
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
"https://w3id.org/security/v1",
|
"https://w3id.org/security/v1",
|
||||||
]
|
]
|
||||||
if include_security
|
else:
|
||||||
else "https://www.w3.org/ns/activitystreams"
|
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)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from statuses.views.home import Home
|
from activities.views.home import Home
|
||||||
from users.models import Identity
|
from users.models import Identity
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -8,4 +8,5 @@ httpx~=0.23
|
||||||
pyOpenSSL~=22.1.0
|
pyOpenSSL~=22.1.0
|
||||||
uvicorn~=0.19
|
uvicorn~=0.19
|
||||||
gunicorn~=20.1.0
|
gunicorn~=20.1.0
|
||||||
psycopg2==2.9.5
|
psycopg2~=2.9.5
|
||||||
|
bleach~=5.0.1
|
||||||
|
|
|
@ -290,3 +290,41 @@ h1.identity small {
|
||||||
.system-note a {
|
.system-note a {
|
||||||
color: inherit;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
import pprint
|
||||||
import traceback
|
import traceback
|
||||||
from typing import ClassVar, List, Optional, Type, Union, cast
|
from typing import ClassVar, List, Optional, Type, Union, cast
|
||||||
|
|
||||||
|
@ -218,10 +219,16 @@ class StatorError(models.Model):
|
||||||
instance: StatorModel,
|
instance: StatorModel,
|
||||||
exception: Optional[BaseException] = None,
|
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(
|
return await cls.objects.acreate(
|
||||||
model_label=instance._meta.label_lower,
|
model_label=instance._meta.label_lower,
|
||||||
instance_pk=str(instance.pk),
|
instance_pk=str(instance.pk),
|
||||||
state=instance.state,
|
state=instance.state,
|
||||||
error=str(exception),
|
error=str(exception),
|
||||||
error_details=traceback.format_exc(),
|
error_details=detail,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from statuses.models import Status
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Status)
|
|
||||||
class StatusAdmin(admin.ModelAdmin):
|
|
||||||
pass
|
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1 +0,0 @@
|
||||||
from .status import Status # noqa
|
|
|
@ -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}/"
|
|
|
@ -24,7 +24,7 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"crispy_forms",
|
"crispy_forms",
|
||||||
"core",
|
"core",
|
||||||
"statuses",
|
"activities",
|
||||||
"users",
|
"users",
|
||||||
"stator",
|
"stator",
|
||||||
]
|
]
|
||||||
|
|
19
templates/activities/_post.html
Normal file
19
templates/activities/_post.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{% load static %}
|
||||||
|
<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 %}
|
||||||
|
|
||||||
|
<h3 class="author">
|
||||||
|
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
|
||||||
|
</h3>
|
||||||
|
<time>
|
||||||
|
<a href="{{ post.urls.view }}">{{ post.created | timesince }} ago</a>
|
||||||
|
</time>
|
||||||
|
<div class="content">
|
||||||
|
{{ post.safe_content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -7,9 +7,9 @@
|
||||||
|
|
||||||
{% crispy form form.helper %}
|
{% crispy form form.helper %}
|
||||||
|
|
||||||
{% for status in statuses %}
|
{% for post in timeline_posts %}
|
||||||
{% include "statuses/_status.html" %}
|
{% include "activities/_post.html" %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
No statuses yet.
|
No posts yet.
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -39,9 +39,9 @@
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for status in statuses %}
|
{% for post in posts %}
|
||||||
{% include "statuses/_status.html" %}
|
{% include "activities/_post.html" %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
No statuses yet.
|
No posts yet.
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
<div class="status">
|
|
||||||
<h3 class="author">
|
|
||||||
<a href="{{ status.identity.urls.view }}">
|
|
||||||
{{ status.identity }}
|
|
||||||
<small>{{ status.identity.handle }}</small>
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<time>
|
|
||||||
<a href="{{ status.urls.view }}">{{ status.created | timesince }} ago</a>
|
|
||||||
</time>
|
|
||||||
{{ status.text | linebreaks }}
|
|
||||||
</div>
|
|
|
@ -21,16 +21,18 @@ class UserEventAdmin(admin.ModelAdmin):
|
||||||
@admin.register(Identity)
|
@admin.register(Identity)
|
||||||
class IdentityAdmin(admin.ModelAdmin):
|
class IdentityAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "handle", "actor_uri", "state", "local"]
|
list_display = ["id", "handle", "actor_uri", "state", "local"]
|
||||||
|
raw_id_fields = ["users"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Follow)
|
@admin.register(Follow)
|
||||||
class FollowAdmin(admin.ModelAdmin):
|
class FollowAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "source", "target", "state"]
|
list_display = ["id", "source", "target", "state"]
|
||||||
|
raw_id_fields = ["source", "target"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(InboxMessage)
|
@admin.register(InboxMessage)
|
||||||
class InboxMessageAdmin(admin.ModelAdmin):
|
class InboxMessageAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "state", "message_type"]
|
list_display = ["id", "state", "state_attempted", "message_type"]
|
||||||
actions = ["reset_state"]
|
actions = ["reset_state"]
|
||||||
|
|
||||||
@admin.action(description="Reset State")
|
@admin.action(description="Reset State")
|
||||||
|
|
|
@ -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
|
import functools
|
||||||
|
|
||||||
|
@ -296,11 +296,14 @@ class Migration(migrations.Migration):
|
||||||
"state",
|
"state",
|
||||||
stator.models.StateField(
|
stator.models.StateField(
|
||||||
choices=[
|
choices=[
|
||||||
("pending", "pending"),
|
("unrequested", "unrequested"),
|
||||||
("requested", "requested"),
|
("local_requested", "local_requested"),
|
||||||
|
("remote_requested", "remote_requested"),
|
||||||
("accepted", "accepted"),
|
("accepted", "accepted"),
|
||||||
|
("undone_locally", "undone_locally"),
|
||||||
|
("undone_remotely", "undone_remotely"),
|
||||||
],
|
],
|
||||||
default="pending",
|
default="unrequested",
|
||||||
graph=users.models.follow.FollowStates,
|
graph=users.models.follow.FollowStates,
|
||||||
max_length=100,
|
max_length=100,
|
||||||
),
|
),
|
||||||
|
|
|
@ -49,10 +49,7 @@ class Domain(models.Model):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_remote_domain(cls, domain: str) -> "Domain":
|
def get_remote_domain(cls, domain: str) -> "Domain":
|
||||||
try:
|
return cls.objects.get_or_create(domain=domain, local=False)[0]
|
||||||
return cls.objects.get(domain=domain, local=False)
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
return cls.objects.create(domain=domain, local=False)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_domain(cls, domain: str) -> Optional["Domain"]:
|
def get_domain(cls, domain: str) -> Optional["Domain"]:
|
||||||
|
@ -93,3 +90,4 @@ class Domain(models.Model):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Service domain {self.service_domain} is already a domain elsewhere!"
|
f"Service domain {self.service_domain} is already a domain elsewhere!"
|
||||||
)
|
)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
|
@ -5,10 +5,11 @@ from django.db import models
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
from core.signatures import HttpSignature
|
from core.signatures import HttpSignature
|
||||||
from stator.models import State, StateField, StateGraph, StatorModel
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
|
from users.models.identity import Identity
|
||||||
|
|
||||||
|
|
||||||
class FollowStates(StateGraph):
|
class FollowStates(StateGraph):
|
||||||
unrequested = State(try_interval=30)
|
unrequested = State(try_interval=300)
|
||||||
local_requested = State(try_interval=24 * 60 * 60)
|
local_requested = State(try_interval=24 * 60 * 60)
|
||||||
remote_requested = State(try_interval=24 * 60 * 60)
|
remote_requested = State(try_interval=24 * 60 * 60)
|
||||||
accepted = State(externally_progressed=True)
|
accepted = State(externally_progressed=True)
|
||||||
|
@ -24,26 +25,19 @@ class FollowStates(StateGraph):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def handle_unrequested(cls, instance: "Follow"):
|
async def handle_unrequested(cls, instance: "Follow"):
|
||||||
# Re-retrieve the follow with more things linked
|
"""
|
||||||
follow = await Follow.objects.select_related(
|
Follows that are unrequested need us to deliver the Follow object
|
||||||
"source", "source__domain", "target"
|
to the target server.
|
||||||
).aget(pk=instance.pk)
|
"""
|
||||||
|
follow = await instance.afetch_full()
|
||||||
# Remote follows should not be here
|
# Remote follows should not be here
|
||||||
if not follow.source.local:
|
if not follow.source.local:
|
||||||
return cls.remote_requested
|
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
|
# Sign it and send it
|
||||||
await HttpSignature.signed_request(
|
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
|
return cls.local_requested
|
||||||
|
|
||||||
|
@ -54,56 +48,28 @@ class FollowStates(StateGraph):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def handle_remote_requested(cls, instance: "Follow"):
|
async def handle_remote_requested(cls, instance: "Follow"):
|
||||||
# Re-retrieve the follow with more things linked
|
"""
|
||||||
follow = await Follow.objects.select_related(
|
Items in remote_requested need us to send an Accept object to the
|
||||||
"source", "source__domain", "target"
|
source server.
|
||||||
).aget(pk=instance.pk)
|
"""
|
||||||
# Send an accept
|
follow = await instance.afetch_full()
|
||||||
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
|
|
||||||
await HttpSignature.signed_request(
|
await HttpSignature.signed_request(
|
||||||
follow.source.inbox_uri,
|
uri=follow.source.inbox_uri,
|
||||||
request,
|
body=canonicalise(follow.to_accept_ap()),
|
||||||
identity=follow.target,
|
identity=follow.target,
|
||||||
)
|
)
|
||||||
return cls.accepted
|
return cls.accepted
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def handle_undone_locally(cls, instance: "Follow"):
|
async def handle_undone_locally(cls, instance: "Follow"):
|
||||||
follow = Follow.objects.select_related(
|
"""
|
||||||
"source", "source__domain", "target"
|
Delivers the Undo object to the target server
|
||||||
).get(pk=instance.pk)
|
"""
|
||||||
# Construct the request
|
follow = await instance.afetch_full()
|
||||||
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
|
|
||||||
await HttpSignature.signed_request(
|
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
|
return cls.undone_remotely
|
||||||
|
|
||||||
|
@ -135,6 +101,11 @@ class Follow(StatorModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [("source", "target")]
|
unique_together = [("source", "target")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"#{self.id}: {self.source} → {self.target}"
|
||||||
|
|
||||||
|
### Alternate fetchers/constructors ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def maybe_get(cls, source, target) -> Optional["Follow"]:
|
def maybe_get(cls, source, target) -> Optional["Follow"]:
|
||||||
"""
|
"""
|
||||||
|
@ -164,22 +135,122 @@ class Follow(StatorModel):
|
||||||
follow.save()
|
follow.save()
|
||||||
return follow
|
return follow
|
||||||
|
|
||||||
|
### 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
|
@classmethod
|
||||||
def remote_created(cls, source, target, uri):
|
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)
|
follow = cls.maybe_get(source=source, target=target)
|
||||||
|
# If it doesn't exist, create one in the remote_requested state
|
||||||
if follow is None:
|
if follow is None:
|
||||||
follow = Follow.objects.create(source=source, target=target, uri=uri)
|
if create:
|
||||||
if follow.state == FollowStates.unrequested:
|
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)
|
follow.transition_perform(FollowStates.remote_requested)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def remote_accepted(cls, source, target):
|
def handle_accept_ap(cls, data):
|
||||||
print(f"accepted follow source {source} target {target}")
|
"""
|
||||||
follow = cls.maybe_get(source=source, target=target)
|
Handles an incoming Follow Accept for one of our follows
|
||||||
print(f"accepting follow {follow}")
|
"""
|
||||||
|
# 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 [
|
if follow and follow.state in [
|
||||||
FollowStates.unrequested,
|
FollowStates.unrequested,
|
||||||
FollowStates.local_requested,
|
FollowStates.local_requested,
|
||||||
]:
|
]:
|
||||||
follow.transition_perform(FollowStates.accepted)
|
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()
|
||||||
|
|
|
@ -55,7 +55,11 @@ class Identity(StatorModel):
|
||||||
state = StateField(IdentityStates)
|
state = StateField(IdentityStates)
|
||||||
|
|
||||||
local = models.BooleanField()
|
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)
|
username = models.CharField(max_length=500, blank=True, null=True)
|
||||||
# Must be a display domain if present
|
# Must be a display domain if present
|
||||||
|
@ -141,18 +145,14 @@ class Identity(StatorModel):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@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:
|
try:
|
||||||
return cls.objects.get(actor_uri=uri)
|
return cls.objects.get(actor_uri=uri)
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
|
if create:
|
||||||
return cls.objects.create(actor_uri=uri, local=False)
|
return cls.objects.create(actor_uri=uri, local=False)
|
||||||
|
else:
|
||||||
|
raise KeyError(f"No identity found matching {uri}")
|
||||||
|
|
||||||
### Dynamic properties ###
|
### Dynamic properties ###
|
||||||
|
|
||||||
|
@ -236,7 +236,7 @@ class Identity(StatorModel):
|
||||||
self.outbox_uri = document.get("outbox")
|
self.outbox_uri = document.get("outbox")
|
||||||
self.summary = document.get("summary")
|
self.summary = document.get("summary")
|
||||||
self.username = document.get("preferredUsername")
|
self.username = document.get("preferredUsername")
|
||||||
if "@value" in self.username:
|
if self.username and "@value" in self.username:
|
||||||
self.username = self.username["@value"]
|
self.username = self.username["@value"]
|
||||||
self.manually_approves_followers = document.get(
|
self.manually_approves_followers = document.get(
|
||||||
"as:manuallyApprovesFollowers"
|
"as:manuallyApprovesFollowers"
|
||||||
|
|
|
@ -2,7 +2,6 @@ from asgiref.sync import sync_to_async
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from stator.models import State, StateField, StateGraph, StatorModel
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
from users.models import Follow, Identity
|
|
||||||
|
|
||||||
|
|
||||||
class InboxMessageStates(StateGraph):
|
class InboxMessageStates(StateGraph):
|
||||||
|
@ -13,23 +12,38 @@ class InboxMessageStates(StateGraph):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def handle_received(cls, instance: "InboxMessage"):
|
async def handle_received(cls, instance: "InboxMessage"):
|
||||||
type = instance.message_type
|
from activities.models import Post
|
||||||
if type == "follow":
|
from users.models import Follow
|
||||||
await instance.follow_request()
|
|
||||||
elif type == "accept":
|
match instance.message_type:
|
||||||
inner_type = instance.message["object"]["type"].lower()
|
case "follow":
|
||||||
if inner_type == "follow":
|
await sync_to_async(Follow.handle_request_ap)(instance.message)
|
||||||
await instance.follow_accepted()
|
case "create":
|
||||||
else:
|
match instance.message_object_type:
|
||||||
raise ValueError(f"Cannot handle activity of type accept.{inner_type}")
|
case "note":
|
||||||
elif type == "undo":
|
await sync_to_async(Post.handle_create_ap)(instance.message)
|
||||||
inner_type = instance.message["object"]["type"].lower()
|
case unknown:
|
||||||
if inner_type == "follow":
|
raise ValueError(
|
||||||
await instance.follow_undo()
|
f"Cannot handle activity of type create.{unknown}"
|
||||||
else:
|
)
|
||||||
raise ValueError(f"Cannot handle activity of type undo.{inner_type}")
|
case "accept":
|
||||||
else:
|
match instance.message_object_type:
|
||||||
raise ValueError(f"Cannot handle activity of type {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
|
return cls.processed
|
||||||
|
|
||||||
|
|
||||||
|
@ -45,35 +59,10 @@ class InboxMessage(StatorModel):
|
||||||
|
|
||||||
state = StateField(InboxMessageStates)
|
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
|
@property
|
||||||
def message_type(self):
|
def message_type(self):
|
||||||
return self.message["type"].lower()
|
return self.message["type"].lower()
|
||||||
|
|
||||||
async def follow_undo(self):
|
@property
|
||||||
"""
|
def message_object_type(self):
|
||||||
Handles an incoming follow undo
|
return self.message["object"]["type"].lower()
|
||||||
"""
|
|
||||||
|
|
|
@ -222,7 +222,7 @@ class Inbox(View):
|
||||||
# Find the Identity by the actor on the incoming item
|
# Find the Identity by the actor on the incoming item
|
||||||
# This ensures that the signature used for the headers matches the actor
|
# This ensures that the signature used for the headers matches the actor
|
||||||
# described in the payload.
|
# 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:
|
if not identity.public_key:
|
||||||
# See if we can fetch it right now
|
# See if we can fetch it right now
|
||||||
async_to_sync(identity.fetch_actor)()
|
async_to_sync(identity.fetch_actor)()
|
||||||
|
|
Loading…
Reference in a new issue