diff options
Diffstat (limited to 'users')
| -rw-r--r-- | users/admin.py | 2 | ||||
| -rw-r--r-- | users/migrations/0001_initial.py | 68 | ||||
| -rw-r--r-- | users/migrations/0002_follow_state_follow_state_attempted_and_more.py | 44 | ||||
| -rw-r--r-- | users/migrations/0003_remove_follow_accepted_remove_follow_requested_and_more.py | 31 | ||||
| -rw-r--r-- | users/migrations/0004_remove_follow_state_locked_and_more.py | 21 | ||||
| -rw-r--r-- | users/migrations/0005_follow_state_locked_until_follow_state_ready.py | 23 | ||||
| -rw-r--r-- | users/models/__init__.py | 1 | ||||
| -rw-r--r-- | users/models/follow.py | 30 | ||||
| -rw-r--r-- | users/models/identity.py | 13 | ||||
| -rw-r--r-- | users/models/inbox_message.py | 71 | ||||
| -rw-r--r-- | users/shortcuts.py | 5 | ||||
| -rw-r--r-- | users/tasks/identity.py | 11 | ||||
| -rw-r--r-- | users/tasks/inbox.py | 56 | ||||
| -rw-r--r-- | users/views/identity.py | 14 | 
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)  | 
