summaryrefslogtreecommitdiffstats
path: root/users
diff options
context:
space:
mode:
Diffstat (limited to 'users')
-rw-r--r--users/admin.py2
-rw-r--r--users/migrations/0001_initial.py68
-rw-r--r--users/migrations/0002_follow_state_follow_state_attempted_and_more.py44
-rw-r--r--users/migrations/0003_remove_follow_accepted_remove_follow_requested_and_more.py31
-rw-r--r--users/migrations/0004_remove_follow_state_locked_and_more.py21
-rw-r--r--users/migrations/0005_follow_state_locked_until_follow_state_ready.py23
-rw-r--r--users/models/__init__.py1
-rw-r--r--users/models/follow.py30
-rw-r--r--users/models/identity.py13
-rw-r--r--users/models/inbox_message.py71
-rw-r--r--users/shortcuts.py5
-rw-r--r--users/tasks/identity.py11
-rw-r--r--users/tasks/inbox.py56
-rw-r--r--users/views/identity.py14
14 files changed, 181 insertions, 209 deletions
diff --git a/users/admin.py b/users/admin.py
index e517b0a..f2b807c 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -20,7 +20,7 @@ class UserEventAdmin(admin.ModelAdmin):
@admin.register(Identity)
class IdentityAdmin(admin.ModelAdmin):
- list_display = ["id", "handle", "actor_uri", "name", "local"]
+ list_display = ["id", "handle", "actor_uri", "state", "local"]
@admin.register(Follow)
diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py
index f5ebf55..2f64337 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-07 04:19
+# Generated by Django 4.1.3 on 2022-11-10 05:58
import functools
@@ -6,7 +6,10 @@ import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
+import stator.models
+import users.models.follow
import users.models.identity
+import users.models.inbox_message
class Migration(migrations.Migration):
@@ -78,6 +81,37 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
+ name="InboxMessage",
+ 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)),
+ ("message", models.JSONField()),
+ (
+ "state",
+ stator.models.StateField(
+ choices=[("received", "received"), ("processed", "processed")],
+ default="received",
+ graph=users.models.inbox_message.InboxMessageStates,
+ max_length=100,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
name="UserEvent",
fields=[
(
@@ -124,7 +158,20 @@ class Migration(migrations.Migration):
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)),
("actor_uri", models.CharField(max_length=500, unique=True)),
+ (
+ "state",
+ stator.models.StateField(
+ choices=[("outdated", "outdated"), ("updated", "updated")],
+ default="outdated",
+ graph=users.models.identity.IdentityStates,
+ max_length=100,
+ ),
+ ),
("local", models.BooleanField()),
("username", models.CharField(blank=True, max_length=500, null=True)),
("name", models.CharField(blank=True, max_length=500, null=True)),
@@ -239,10 +286,25 @@ class Migration(migrations.Migration):
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)),
("uri", models.CharField(blank=True, max_length=500, null=True)),
("note", models.TextField(blank=True, null=True)),
- ("requested", models.BooleanField(default=False)),
- ("accepted", models.BooleanField(default=False)),
+ (
+ "state",
+ stator.models.StateField(
+ choices=[
+ ("pending", "pending"),
+ ("requested", "requested"),
+ ("accepted", "accepted"),
+ ],
+ default="pending",
+ graph=users.models.follow.FollowStates,
+ max_length=100,
+ ),
+ ),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
diff --git a/users/migrations/0002_follow_state_follow_state_attempted_and_more.py b/users/migrations/0002_follow_state_follow_state_attempted_and_more.py
deleted file mode 100644
index b33236a..0000000
--- a/users/migrations/0002_follow_state_follow_state_attempted_and_more.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Generated by Django 4.1.3 on 2022-11-07 19:22
-
-import django.utils.timezone
-from django.db import migrations, models
-
-import stator.models
-import users.models.follow
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("users", "0001_initial"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="follow",
- name="state",
- field=stator.models.StateField(
- choices=[
- ("pending", "pending"),
- ("requested", "requested"),
- ("accepted", "accepted"),
- ],
- default="pending",
- graph=users.models.follow.FollowStates,
- max_length=100,
- ),
- ),
- migrations.AddField(
- model_name="follow",
- name="state_attempted",
- field=models.DateTimeField(blank=True, null=True),
- ),
- migrations.AddField(
- model_name="follow",
- name="state_changed",
- field=models.DateTimeField(
- auto_now_add=True, default=django.utils.timezone.now
- ),
- preserve_default=False,
- ),
- ]
diff --git a/users/migrations/0003_remove_follow_accepted_remove_follow_requested_and_more.py b/users/migrations/0003_remove_follow_accepted_remove_follow_requested_and_more.py
deleted file mode 100644
index 180bfdd..0000000
--- a/users/migrations/0003_remove_follow_accepted_remove_follow_requested_and_more.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# Generated by Django 4.1.3 on 2022-11-08 03:58
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("users", "0002_follow_state_follow_state_attempted_and_more"),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name="follow",
- name="accepted",
- ),
- migrations.RemoveField(
- model_name="follow",
- name="requested",
- ),
- migrations.AddField(
- model_name="follow",
- name="state_locked",
- field=models.DateTimeField(blank=True, null=True),
- ),
- migrations.AddField(
- model_name="follow",
- name="state_runner",
- field=models.CharField(blank=True, max_length=100, null=True),
- ),
- ]
diff --git a/users/migrations/0004_remove_follow_state_locked_and_more.py b/users/migrations/0004_remove_follow_state_locked_and_more.py
deleted file mode 100644
index bf98080..0000000
--- a/users/migrations/0004_remove_follow_state_locked_and_more.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# Generated by Django 4.1.3 on 2022-11-09 05:15
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("users", "0003_remove_follow_accepted_remove_follow_requested_and_more"),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name="follow",
- name="state_locked",
- ),
- migrations.RemoveField(
- model_name="follow",
- name="state_runner",
- ),
- ]
diff --git a/users/migrations/0005_follow_state_locked_until_follow_state_ready.py b/users/migrations/0005_follow_state_locked_until_follow_state_ready.py
deleted file mode 100644
index 3aba08e..0000000
--- a/users/migrations/0005_follow_state_locked_until_follow_state_ready.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 4.1.3 on 2022-11-10 03:24
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("users", "0004_remove_follow_state_locked_and_more"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="follow",
- name="state_locked_until",
- field=models.DateTimeField(blank=True, null=True),
- ),
- migrations.AddField(
- model_name="follow",
- name="state_ready",
- field=models.BooleanField(default=False),
- ),
- ]
diff --git a/users/models/__init__.py b/users/models/__init__.py
index d46003f..28d62b0 100644
--- a/users/models/__init__.py
+++ b/users/models/__init__.py
@@ -2,5 +2,6 @@ from .block import Block # noqa
from .domain import Domain # noqa
from .follow import Follow, FollowStates # noqa
from .identity import Identity, IdentityStates # noqa
+from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .user import User # noqa
from .user_event import UserEvent # noqa
diff --git a/users/models/follow.py b/users/models/follow.py
index 3325a0b..6f62481 100644
--- a/users/models/follow.py
+++ b/users/models/follow.py
@@ -6,16 +6,20 @@ from stator.models import State, StateField, StateGraph, StatorModel
class FollowStates(StateGraph):
- pending = State(try_interval=30)
- requested = State()
+ unrequested = State(try_interval=30)
+ requested = State(try_interval=24 * 60 * 60)
accepted = State()
- @pending.add_transition(requested)
- async def try_request(instance: "Follow"): # type:ignore
+ unrequested.transitions_to(requested)
+ requested.transitions_to(accepted)
+
+ @classmethod
+ async def handle_unrequested(cls, instance: "Follow"):
print("Would have tried to follow on", instance)
- return False
- requested.add_manual_transition(accepted)
+ @classmethod
+ async def handle_requested(cls, instance: "Follow"):
+ print("Would have tried to requested on", instance)
class Follow(StatorModel):
@@ -73,3 +77,17 @@ class Follow(StatorModel):
follow.state = FollowStates.accepted
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.fresh:
+ follow.transition_perform(FollowStates.requested)
+
+ @classmethod
+ def remote_accepted(cls, source, target):
+ follow = cls.maybe_get(source=source, target=target)
+ if follow and follow.state == FollowStates.requested:
+ follow.transition_perform(FollowStates.accepted)
diff --git a/users/models/identity.py b/users/models/identity.py
index 5e2cd06..7dff492 100644
--- a/users/models/identity.py
+++ b/users/models/identity.py
@@ -22,11 +22,16 @@ class IdentityStates(StateGraph):
outdated = State(try_interval=3600)
updated = State()
- @outdated.add_transition(updated)
- async def fetch_identity(identity: "Identity"): # type:ignore
+ outdated.transitions_to(updated)
+
+ @classmethod
+ async def handle_outdated(cls, identity: "Identity"):
+ # Local identities never need fetching
if identity.local:
- return True
- return await identity.fetch_actor()
+ return "updated"
+ # Run the actor fetch and progress to updated if it succeeds
+ if await identity.fetch_actor():
+ return "updated"
def upload_namer(prefix, instance, filename):
diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py
new file mode 100644
index 0000000..0dbdc3a
--- /dev/null
+++ b/users/models/inbox_message.py
@@ -0,0 +1,71 @@
+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):
+ received = State(try_interval=300)
+ processed = State()
+
+ received.transitions_to(processed)
+
+ @classmethod
+ async def handle_received(cls, instance: "InboxMessage"):
+ type = instance.message["type"].lower()
+ 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}")
+
+
+class InboxMessage(StatorModel):
+ """
+ an incoming inbox message that needs processing.
+
+ Yes, this is kind of its own message queue built on the state graph system.
+ It's fine. It'll scale up to a decent point.
+ """
+
+ message = models.JSONField()
+
+ 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
+ """
+ Follow.remote_accepted(
+ source=Identity.by_actor_uri_with_create(self.message["actor"]),
+ target=Identity.by_actor_uri(self.message["object"]),
+ )
+
+ async def follow_undo(self):
+ """
+ Handles an incoming follow undo
+ """
diff --git a/users/shortcuts.py b/users/shortcuts.py
index 3e7618a..8e20a09 100644
--- a/users/shortcuts.py
+++ b/users/shortcuts.py
@@ -19,10 +19,7 @@ def by_handle_or_404(request, handle, local=True, fetch=False) -> Identity:
else:
username, domain = handle.split("@", 1)
# Resolve the domain to the display domain
- domain_instance = Domain.get_domain(domain)
- if domain_instance is None:
- raise Http404("No matching domains found")
- domain = domain_instance.domain
+ domain = Domain.get_remote_domain(domain).domain
identity = Identity.by_username_and_domain(
username,
domain,
diff --git a/users/tasks/identity.py b/users/tasks/identity.py
deleted file mode 100644
index f5cd214..0000000
--- a/users/tasks/identity.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from asgiref.sync import sync_to_async
-
-from users.models import Identity
-
-
-async def handle_identity_fetch(task_handler):
- # Get the actor URI via webfinger
- actor_uri, handle = await Identity.fetch_webfinger(task_handler.subject)
- # Get or create the identity, then fetch
- identity = await sync_to_async(Identity.by_actor_uri_with_create)(actor_uri)
- await identity.fetch_actor()
diff --git a/users/tasks/inbox.py b/users/tasks/inbox.py
deleted file mode 100644
index 27c602d..0000000
--- a/users/tasks/inbox.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from asgiref.sync import sync_to_async
-
-from users.models import Follow, Identity
-
-
-async def handle_inbox_item(task_handler):
- type = task_handler.payload["type"].lower()
- if type == "follow":
- await inbox_follow(task_handler.payload)
- elif type == "accept":
- inner_type = task_handler.payload["object"]["type"].lower()
- if inner_type == "follow":
- await sync_to_async(accept_follow)(task_handler.payload["object"])
- else:
- raise ValueError(f"Cannot handle activity of type accept.{inner_type}")
- elif type == "undo":
- inner_type = task_handler.payload["object"]["type"].lower()
- if inner_type == "follow":
- await inbox_unfollow(task_handler.payload["object"])
- else:
- raise ValueError(f"Cannot handle activity of type undo.{inner_type}")
- else:
- raise ValueError(f"Cannot handle activity of type {inner_type}")
-
-
-async def inbox_follow(payload):
- """
- Handles an incoming follow request
- """
- # TODO: Manually approved follows
- source = Identity.by_actor_uri_with_create(payload["actor"])
- target = Identity.by_actor_uri(payload["object"])
- # See if this follow already exists
- try:
- follow = Follow.objects.get(source=source, target=target)
- except Follow.DoesNotExist:
- follow = Follow.objects.create(source=source, target=target, uri=payload["id"])
- # See if we need to acknowledge it
- if not follow.acknowledged:
- pass
-
-
-async def inbox_unfollow(payload):
- pass
-
-
-def accept_follow(payload):
- """
- Another server has acknowledged our follow request
- """
- source = Identity.by_actor_uri_with_create(payload["actor"])
- target = Identity.by_actor_uri(payload["object"])
- follow = Follow.maybe_get(source, target)
- if follow:
- follow.accepted = True
- follow.save()
diff --git a/users/views/identity.py b/users/views/identity.py
index d02505f..3e69dae 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -17,7 +17,7 @@ from core.forms import FormHelper
from core.ld import canonicalise
from core.signatures import HttpSignature
from users.decorators import identity_required
-from users.models import Domain, Follow, Identity, IdentityStates
+from users.models import Domain, Follow, Identity, IdentityStates, InboxMessage
from users.shortcuts import by_handle_or_404
@@ -117,9 +117,13 @@ class CreateIdentity(FormView):
def clean(self):
# Check for existing users
- username = self.cleaned_data["username"]
- domain = self.cleaned_data["domain"]
- if Identity.objects.filter(username=username, domain=domain).exists():
+ username = self.cleaned_data.get("username")
+ domain = self.cleaned_data.get("domain")
+ if (
+ username
+ and domain
+ and Identity.objects.filter(username=username, domain=domain).exists()
+ ):
raise forms.ValidationError(f"{username}@{domain} is already taken")
def get_form(self):
@@ -219,7 +223,7 @@ class Inbox(View):
):
return HttpResponseBadRequest("Bad signature")
# Hand off the item to be processed by the queue
- Task.submit("inbox_item", subject=identity.actor_uri, payload=document)
+ InboxMessage.objects.create(message=document)
return HttpResponse(status=202)