From c391e7bc4151ae148d42acd8bbe303338cdde31c Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Mon, 7 Nov 2022 00:19:00 -0700 Subject: [PATCH] THE FOLLOWS, THEY WORK Well, in one direction anyway --- core/middleware.py | 13 +++++++++++++ core/signatures.py | 2 +- takahe/settings.py | 1 + users/admin.py | 8 ++++++-- users/models/identity.py | 21 ++++++++++----------- users/shortcuts.py | 9 ++++++++- users/tasks/follow.py | 5 +++-- users/tasks/inbox.py | 24 ++++++++++++++++++++++-- users/views/identity.py | 13 +++++++------ 9 files changed, 71 insertions(+), 25 deletions(-) create mode 100644 core/middleware.py diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..8e95f06 --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,13 @@ +class AlwaysSecureMiddleware: + """ + Locks the request object as always being secure, for when it's behind + a HTTPS reverse proxy. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request.__class__.scheme = "https" + response = self.get_response(request) + return response diff --git a/core/signatures.py b/core/signatures.py index 6f4d9ef..805ae91 100644 --- a/core/signatures.py +++ b/core/signatures.py @@ -96,7 +96,7 @@ class HttpSignature: ) headers["Signature"] = self.compile_signature( { - "keyid": identity.urls.key.full(), # type:ignore + "keyid": identity.key_id, "headers": list(headers.keys()), "signature": identity.sign(signed_string), "algorithm": "rsa-sha256", diff --git a/takahe/settings.py b/takahe/settings.py index 78a8403..fea5244 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -29,6 +29,7 @@ INSTALLED_APPS = [ ] MIDDLEWARE = [ + "core.middleware.AlwaysSecureMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", diff --git a/users/admin.py b/users/admin.py index bb07aa1..0b0cc80 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from users.models import Domain, Identity, User, UserEvent +from users.models import Domain, Follow, Identity, User, UserEvent @admin.register(Domain) @@ -20,5 +20,9 @@ class UserEventAdmin(admin.ModelAdmin): @admin.register(Identity) class IdentityAdmin(admin.ModelAdmin): - list_display = ["id", "handle", "actor_uri", "name", "local"] + + +@admin.register(Follow) +class FollowAdmin(admin.ModelAdmin): + list_display = ["id", "source", "target", "requested", "accepted"] diff --git a/users/models/identity.py b/users/models/identity.py index 1f44e98..98262bc 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -82,11 +82,7 @@ class Identity(models.Model): view = "/@{self.username}@{self.domain_id}/" view_short = "/@{self.username}/" action = "{view}action/" - actor = "{view}actor/" activate = "{view}activate/" - key = "{actor}#main-key" - inbox = "{actor}inbox/" - outbox = "{actor}outbox/" def get_scheme(self, url): return "https" @@ -102,12 +98,9 @@ class Identity(models.Model): ### Alternate constructors/fetchers ### @classmethod - def by_handle(cls, handle, fetch=False, local=False): - if handle.startswith("@"): - raise ValueError("Handle must not start with @") - if "@" not in handle: - raise ValueError("Handle must contain domain") - username, domain = handle.split("@") + def by_username_and_domain(cls, username, domain, fetch=False, local=False): + if username.startswith("@"): + raise ValueError("Username must not start with @") try: if local: return cls.objects.get(username=username, domain_id=domain, local=True) @@ -115,7 +108,9 @@ class Identity(models.Model): return cls.objects.get(username=username, domain_id=domain) except cls.DoesNotExist: if fetch and not local: - actor_uri, handle = async_to_sync(cls.fetch_webfinger)(handle) + actor_uri, handle = async_to_sync(cls.fetch_webfinger)( + f"{username}@{domain}" + ) username, domain = handle.split("@") domain = Domain.get_remote_domain(domain) return cls.objects.create( @@ -168,6 +163,10 @@ class Identity(models.Model): # TODO: Setting return self.data_age > 60 * 24 * 24 + @property + def key_id(self): + return self.actor_uri + "#main-key" + ### Actor/Webfinger fetching ### @classmethod diff --git a/users/shortcuts.py b/users/shortcuts.py index 15b864d..65206a3 100644 --- a/users/shortcuts.py +++ b/users/shortcuts.py @@ -18,7 +18,14 @@ def by_handle_or_404(request, handle, local=True, fetch=False): domain = domain_instance.domain else: username, domain = handle.split("@", 1) - identity = Identity.by_handle(handle, local=local, fetch=fetch) + # Resolve the domain to the display domain + domain = Domain.get_local_domain(request.META["HTTP_HOST"]).domain + identity = Identity.by_username_and_domain( + username, + domain, + local=local, + fetch=fetch, + ) if identity is None: raise Http404(f"No identity for handle {handle}") return identity diff --git a/users/tasks/follow.py b/users/tasks/follow.py index 3260124..872b35f 100644 --- a/users/tasks/follow.py +++ b/users/tasks/follow.py @@ -24,5 +24,6 @@ async def handle_follow_request(task_handler): response = await HttpSignature.signed_request( follow.target.inbox_uri, request, follow.source ) - print(response) - print(response.content) + if response.status_code >= 400: + raise ValueError(f"Request error: {response.status_code} {response.content}") + await Follow.objects.filter(pk=follow.pk).aupdate(requested=True) diff --git a/users/tasks/inbox.py b/users/tasks/inbox.py index ab80648..27c602d 100644 --- a/users/tasks/inbox.py +++ b/users/tasks/inbox.py @@ -1,3 +1,5 @@ +from asgiref.sync import sync_to_async + from users.models import Follow, Identity @@ -5,14 +7,20 @@ 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("Cannot undo activity of type {inner_type}") + raise ValueError(f"Cannot handle activity of type undo.{inner_type}") else: - raise ValueError("Cannot handle activity of type {inner_type}") + raise ValueError(f"Cannot handle activity of type {inner_type}") async def inbox_follow(payload): @@ -34,3 +42,15 @@ async def inbox_follow(payload): 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 7cba43e..98fcdd6 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -130,8 +130,9 @@ class CreateIdentity(FormView): def form_valid(self, form): username = form.cleaned_data["username"] domain = form.cleaned_data["domain"] + domain_instance = Domain.get_local_domain(domain) new_identity = Identity.objects.create( - actor_uri=f"https://{domain}/@{username}/actor/", + actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/actor/", username=username, domain_id=domain, name=form.cleaned_data["name"], @@ -154,13 +155,13 @@ class Actor(View): "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", ], - "id": identity.urls.actor.full(), + "id": identity.actor_uri, "type": "Person", - "inbox": identity.urls.inbox.full(), + "inbox": identity.actor_uri + "inbox/", "preferredUsername": identity.username, "publicKey": { - "id": identity.urls.key.full(), - "owner": identity.urls.actor.full(), + "id": identity.key_id, + "owner": identity.actor_uri, "publicKeyPem": identity.public_key, }, "published": identity.created.strftime("%Y-%m-%dT%H:%M:%SZ"), @@ -249,7 +250,7 @@ class Webfinger(View): { "rel": "self", "type": "application/activity+json", - "href": identity.urls.actor.full(), + "href": identity.actor_uri, }, ], }