mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-22 07:10:59 +00:00
Posting and fan-out both working
This commit is contained in:
parent
feb5d9b74f
commit
8fd5a9292c
11 changed files with 295 additions and 17 deletions
|
@ -1,11 +1,11 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from activities.models import Post, TimelineEvent
|
from activities.models import FanOut, Post, TimelineEvent
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Post)
|
@admin.register(Post)
|
||||||
class PostAdmin(admin.ModelAdmin):
|
class PostAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "author", "created"]
|
list_display = ["id", "state", "author", "created"]
|
||||||
raw_id_fields = ["to", "mentions"]
|
raw_id_fields = ["to", "mentions"]
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,3 +13,9 @@ class PostAdmin(admin.ModelAdmin):
|
||||||
class TimelineEventAdmin(admin.ModelAdmin):
|
class TimelineEventAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "identity", "created", "type"]
|
list_display = ["id", "identity", "created", "type"]
|
||||||
raw_id_fields = ["identity", "subject_post", "subject_identity"]
|
raw_id_fields = ["identity", "subject_post", "subject_identity"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FanOut)
|
||||||
|
class FanOutAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "state", "state_attempted", "type", "identity"]
|
||||||
|
raw_id_fields = ["identity", "subject_post"]
|
||||||
|
|
|
@ -28,7 +28,7 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="ID",
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("state_ready", models.BooleanField(default=False)),
|
("state_ready", models.BooleanField(default=True)),
|
||||||
("state_changed", models.DateTimeField(auto_now_add=True)),
|
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||||
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
||||||
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
||||||
|
|
103
activities/migrations/0002_fan_out.py
Normal file
103
activities/migrations/0002_fan_out.py
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
# Generated by Django 4.1.3 on 2022-11-12 05:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import activities.models.fan_out
|
||||||
|
import stator.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0001_initial"),
|
||||||
|
("activities", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="post",
|
||||||
|
name="authored",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="post",
|
||||||
|
name="author",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="posts",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="post",
|
||||||
|
name="mentions",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, related_name="posts_mentioning", to="users.identity"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="post",
|
||||||
|
name="to",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, related_name="posts_to", to="users.identity"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FanOut",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("state_ready", models.BooleanField(default=True)),
|
||||||
|
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"state",
|
||||||
|
stator.models.StateField(
|
||||||
|
choices=[("new", "new"), ("sent", "sent")],
|
||||||
|
default="new",
|
||||||
|
graph=activities.models.fan_out.FanOutStates,
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("post", "Post"), ("boost", "Boost")], max_length=100
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"identity",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="fan_outs",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"subject_post",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="fan_outs",
|
||||||
|
to="activities.post",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,2 +1,3 @@
|
||||||
|
from .fan_out import FanOut # noqa
|
||||||
from .post import Post # noqa
|
from .post import Post # noqa
|
||||||
from .timeline_event import TimelineEvent # noqa
|
from .timeline_event import TimelineEvent # noqa
|
||||||
|
|
81
activities/models/fan_out.py
Normal file
81
activities/models/fan_out.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from activities.models.timeline_event import TimelineEvent
|
||||||
|
from core.ld import canonicalise
|
||||||
|
from core.signatures import HttpSignature
|
||||||
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
|
|
||||||
|
|
||||||
|
class FanOutStates(StateGraph):
|
||||||
|
new = State(try_interval=300)
|
||||||
|
sent = State()
|
||||||
|
|
||||||
|
new.transitions_to(sent)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def handle_new(cls, instance: "FanOut"):
|
||||||
|
"""
|
||||||
|
Sends the fan-out to the right inbox.
|
||||||
|
"""
|
||||||
|
fan_out = await instance.afetch_full()
|
||||||
|
if fan_out.identity.local:
|
||||||
|
# Make a timeline event directly
|
||||||
|
await sync_to_async(TimelineEvent.add_post)(
|
||||||
|
identity=fan_out.identity,
|
||||||
|
post=fan_out.subject_post,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Send it to the remote inbox
|
||||||
|
post = await fan_out.subject_post.afetch_full()
|
||||||
|
# Sign it and send it
|
||||||
|
await HttpSignature.signed_request(
|
||||||
|
uri=fan_out.identity.inbox_uri,
|
||||||
|
body=canonicalise(post.to_create_ap()),
|
||||||
|
identity=post.author,
|
||||||
|
)
|
||||||
|
return cls.sent
|
||||||
|
|
||||||
|
|
||||||
|
class FanOut(StatorModel):
|
||||||
|
"""
|
||||||
|
An activity that needs to get to an inbox somewhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Types(models.TextChoices):
|
||||||
|
post = "post"
|
||||||
|
boost = "boost"
|
||||||
|
|
||||||
|
state = StateField(FanOutStates)
|
||||||
|
|
||||||
|
# The user this event is targeted at
|
||||||
|
identity = models.ForeignKey(
|
||||||
|
"users.Identity",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="fan_outs",
|
||||||
|
)
|
||||||
|
|
||||||
|
# What type of activity it is
|
||||||
|
type = models.CharField(max_length=100, choices=Types.choices)
|
||||||
|
|
||||||
|
# Links to the appropriate objects
|
||||||
|
subject_post = models.ForeignKey(
|
||||||
|
"activities.Post",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
related_name="fan_outs",
|
||||||
|
)
|
||||||
|
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
### Async helpers ###
|
||||||
|
|
||||||
|
async def afetch_full(self):
|
||||||
|
"""
|
||||||
|
Returns a version of the object with all relations pre-loaded
|
||||||
|
"""
|
||||||
|
return await FanOut.objects.select_related("identity", "subject_post").aget(
|
||||||
|
pk=self.pk
|
||||||
|
)
|
|
@ -1,8 +1,13 @@
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
import urlman
|
import urlman
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from activities.models.fan_out import FanOut
|
||||||
from activities.models.timeline_event import TimelineEvent
|
from activities.models.timeline_event import TimelineEvent
|
||||||
from core.html import sanitize_post
|
from core.html import sanitize_post
|
||||||
|
from core.ld import format_date
|
||||||
from stator.models import State, StateField, StateGraph, StatorModel
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
from users.models.follow import Follow
|
from users.models.follow import Follow
|
||||||
from users.models.identity import Identity
|
from users.models.identity import Identity
|
||||||
|
@ -19,7 +24,8 @@ class PostStates(StateGraph):
|
||||||
"""
|
"""
|
||||||
Creates all needed fan-out objects for a new Post.
|
Creates all needed fan-out objects for a new Post.
|
||||||
"""
|
"""
|
||||||
pass
|
await instance.afan_out()
|
||||||
|
return cls.fanned_out
|
||||||
|
|
||||||
|
|
||||||
class Post(StatorModel):
|
class Post(StatorModel):
|
||||||
|
@ -84,11 +90,21 @@ class Post(StatorModel):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When the post was originally created (as opposed to when we received it)
|
||||||
|
authored = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
updated = models.DateTimeField(auto_now=True)
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
view = "{self.identity.urls.view}posts/{self.id}/"
|
view = "{self.author.urls.view}posts/{self.id}/"
|
||||||
|
object_uri = "{self.author.urls.actor}posts/{self.id}/"
|
||||||
|
|
||||||
|
def get_scheme(self, url):
|
||||||
|
return "https"
|
||||||
|
|
||||||
|
def get_hostname(self, url):
|
||||||
|
return self.instance.author.domain.uri_domain
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.author} #{self.id}"
|
return f"{self.author} #{self.id}"
|
||||||
|
@ -97,6 +113,16 @@ class Post(StatorModel):
|
||||||
def safe_content(self):
|
def safe_content(self):
|
||||||
return sanitize_post(self.content)
|
return sanitize_post(self.content)
|
||||||
|
|
||||||
|
### Async helpers ###
|
||||||
|
|
||||||
|
async def afetch_full(self):
|
||||||
|
"""
|
||||||
|
Returns a version of the object with all relations pre-loaded
|
||||||
|
"""
|
||||||
|
return await Post.objects.select_related("author", "author__domain").aget(
|
||||||
|
pk=self.pk
|
||||||
|
)
|
||||||
|
|
||||||
### Local creation ###
|
### Local creation ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -111,9 +137,57 @@ class Post(StatorModel):
|
||||||
post.save()
|
post.save()
|
||||||
return post
|
return post
|
||||||
|
|
||||||
### ActivityPub (outgoing) ###
|
### ActivityPub (outbound) ###
|
||||||
|
|
||||||
### ActivityPub (incoming) ###
|
async def afan_out(self):
|
||||||
|
"""
|
||||||
|
Creates FanOuts for a new post
|
||||||
|
"""
|
||||||
|
# Send a copy to all people who follow this user
|
||||||
|
post = await self.afetch_full()
|
||||||
|
async for follow in post.author.inbound_follows.all():
|
||||||
|
await FanOut.objects.acreate(
|
||||||
|
identity_id=follow.source_id,
|
||||||
|
type=FanOut.Types.post,
|
||||||
|
subject_post=post,
|
||||||
|
)
|
||||||
|
# And one for themselves
|
||||||
|
await FanOut.objects.acreate(
|
||||||
|
identity_id=post.author_id,
|
||||||
|
type=FanOut.Types.post,
|
||||||
|
subject_post=post,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_ap(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Returns the AP JSON for this object
|
||||||
|
"""
|
||||||
|
value = {
|
||||||
|
"type": "Note",
|
||||||
|
"id": self.object_uri,
|
||||||
|
"published": format_date(self.created),
|
||||||
|
"attributedTo": self.author.actor_uri,
|
||||||
|
"content": self.safe_content,
|
||||||
|
"to": "as:Public",
|
||||||
|
"as:sensitive": self.sensitive,
|
||||||
|
"url": self.urls.view.full(), # type: ignore
|
||||||
|
}
|
||||||
|
if self.summary:
|
||||||
|
value["summary"] = self.summary
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_create_ap(self):
|
||||||
|
"""
|
||||||
|
Returns the AP JSON to create this object
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"type": "Create",
|
||||||
|
"id": self.object_uri + "#create",
|
||||||
|
"actor": self.author.actor_uri,
|
||||||
|
"object": self.to_ap(),
|
||||||
|
}
|
||||||
|
|
||||||
|
### ActivityPub (inbound) ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def by_ap(cls, data, create=False) -> "Post":
|
def by_ap(cls, data, create=False) -> "Post":
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import datetime
|
||||||
import urllib.parse as urllib_parse
|
import urllib.parse as urllib_parse
|
||||||
from typing import Dict, List, Union
|
from typing import Dict, List, Union
|
||||||
|
|
||||||
|
@ -273,6 +274,8 @@ schemas = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
|
||||||
|
|
||||||
def builtin_document_loader(url: str, options={}):
|
def builtin_document_loader(url: str, options={}):
|
||||||
# Get URL without scheme
|
# Get URL without scheme
|
||||||
|
@ -324,3 +327,7 @@ def canonicalise(json_data: Dict, include_security: bool = False) -> Dict:
|
||||||
json_data["@context"] = context
|
json_data["@context"] = context
|
||||||
|
|
||||||
return jsonld.compact(jsonld.expand(json_data), context)
|
return jsonld.compact(jsonld.expand(json_data), context)
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(value: datetime.datetime) -> str:
|
||||||
|
return value.strftime(DATETIME_FORMAT)
|
||||||
|
|
|
@ -45,8 +45,8 @@ class StatorModel(models.Model):
|
||||||
concrete model yourself.
|
concrete model yourself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If this row is up for transition attempts
|
# If this row is up for transition attempts (which it always is on creation!)
|
||||||
state_ready = models.BooleanField(default=False)
|
state_ready = models.BooleanField(default=True)
|
||||||
|
|
||||||
# When the state last actually changed, or the date of instance creation
|
# When the state last actually changed, or the date of instance creation
|
||||||
state_changed = models.DateTimeField(auto_now_add=True)
|
state_changed = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
|
@ -8,7 +8,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h3 class="author">
|
<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>
|
</h3>
|
||||||
<time>
|
<time>
|
||||||
<a href="{{ post.urls.view }}">{{ post.created | timesince }} ago</a>
|
<a href="{{ post.urls.view }}">{{ post.created | timesince }} ago</a>
|
||||||
|
|
|
@ -92,7 +92,7 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="ID",
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("state_ready", models.BooleanField(default=False)),
|
("state_ready", models.BooleanField(default=True)),
|
||||||
("state_changed", models.DateTimeField(auto_now_add=True)),
|
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||||
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
||||||
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
||||||
|
@ -158,7 +158,7 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="ID",
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("state_ready", models.BooleanField(default=False)),
|
("state_ready", models.BooleanField(default=True)),
|
||||||
("state_changed", models.DateTimeField(auto_now_add=True)),
|
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||||
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
||||||
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
||||||
|
@ -230,7 +230,9 @@ class Migration(migrations.Migration):
|
||||||
(
|
(
|
||||||
"users",
|
"users",
|
||||||
models.ManyToManyField(
|
models.ManyToManyField(
|
||||||
related_name="identities", to=settings.AUTH_USER_MODEL
|
blank=True,
|
||||||
|
related_name="identities",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -286,7 +288,7 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="ID",
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("state_ready", models.BooleanField(default=False)),
|
("state_ready", models.BooleanField(default=True)),
|
||||||
("state_changed", models.DateTimeField(auto_now_add=True)),
|
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||||
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
||||||
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
||||||
|
|
|
@ -36,12 +36,12 @@ class ViewIdentity(TemplateView):
|
||||||
local=False,
|
local=False,
|
||||||
fetch=True,
|
fetch=True,
|
||||||
)
|
)
|
||||||
statuses = identity.statuses.all()[:100]
|
posts = identity.posts.all()[:100]
|
||||||
if identity.data_age > settings.IDENTITY_MAX_AGE:
|
if identity.data_age > settings.IDENTITY_MAX_AGE:
|
||||||
identity.transition_perform(IdentityStates.outdated)
|
identity.transition_perform(IdentityStates.outdated)
|
||||||
return {
|
return {
|
||||||
"identity": identity,
|
"identity": identity,
|
||||||
"statuses": statuses,
|
"posts": posts,
|
||||||
"follow": Follow.maybe_get(self.request.identity, identity)
|
"follow": Follow.maybe_get(self.request.identity, identity)
|
||||||
if self.request.identity
|
if self.request.identity
|
||||||
else None,
|
else None,
|
||||||
|
@ -232,9 +232,11 @@ class Inbox(View):
|
||||||
if not identity.verify_signature(
|
if not identity.verify_signature(
|
||||||
signature_details["signature"], headers_string
|
signature_details["signature"], headers_string
|
||||||
):
|
):
|
||||||
|
print("Bad signature!")
|
||||||
|
print(document)
|
||||||
return HttpResponseUnauthorized("Bad signature")
|
return HttpResponseUnauthorized("Bad signature")
|
||||||
# Hand off the item to be processed by the queue
|
# Hand off the item to be processed by the queue
|
||||||
InboxMessage.objects.create(message=document, state_ready=True)
|
InboxMessage.objects.create(message=document)
|
||||||
return HttpResponse(status=202)
|
return HttpResponse(status=202)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue