THE FOLLOWS, THEY WORK

Well, in one direction anyway
This commit is contained in:
Andrew Godwin 2022-11-07 00:19:00 -07:00
parent fb6c409a9a
commit c391e7bc41
9 changed files with 71 additions and 25 deletions

13
core/middleware.py Normal file
View File

@ -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

View File

@ -96,7 +96,7 @@ class HttpSignature:
) )
headers["Signature"] = self.compile_signature( headers["Signature"] = self.compile_signature(
{ {
"keyid": identity.urls.key.full(), # type:ignore "keyid": identity.key_id,
"headers": list(headers.keys()), "headers": list(headers.keys()),
"signature": identity.sign(signed_string), "signature": identity.sign(signed_string),
"algorithm": "rsa-sha256", "algorithm": "rsa-sha256",

View File

@ -29,6 +29,7 @@ INSTALLED_APPS = [
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"core.middleware.AlwaysSecureMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",

View File

@ -1,6 +1,6 @@
from django.contrib import admin 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) @admin.register(Domain)
@ -20,5 +20,9 @@ class UserEventAdmin(admin.ModelAdmin):
@admin.register(Identity) @admin.register(Identity)
class IdentityAdmin(admin.ModelAdmin): class IdentityAdmin(admin.ModelAdmin):
list_display = ["id", "handle", "actor_uri", "name", "local"] list_display = ["id", "handle", "actor_uri", "name", "local"]
@admin.register(Follow)
class FollowAdmin(admin.ModelAdmin):
list_display = ["id", "source", "target", "requested", "accepted"]

View File

@ -82,11 +82,7 @@ class Identity(models.Model):
view = "/@{self.username}@{self.domain_id}/" view = "/@{self.username}@{self.domain_id}/"
view_short = "/@{self.username}/" view_short = "/@{self.username}/"
action = "{view}action/" action = "{view}action/"
actor = "{view}actor/"
activate = "{view}activate/" activate = "{view}activate/"
key = "{actor}#main-key"
inbox = "{actor}inbox/"
outbox = "{actor}outbox/"
def get_scheme(self, url): def get_scheme(self, url):
return "https" return "https"
@ -102,12 +98,9 @@ class Identity(models.Model):
### Alternate constructors/fetchers ### ### Alternate constructors/fetchers ###
@classmethod @classmethod
def by_handle(cls, handle, fetch=False, local=False): def by_username_and_domain(cls, username, domain, fetch=False, local=False):
if handle.startswith("@"): if username.startswith("@"):
raise ValueError("Handle must not start with @") raise ValueError("Username must not start with @")
if "@" not in handle:
raise ValueError("Handle must contain domain")
username, domain = handle.split("@")
try: try:
if local: if local:
return cls.objects.get(username=username, domain_id=domain, local=True) 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) return cls.objects.get(username=username, domain_id=domain)
except cls.DoesNotExist: except cls.DoesNotExist:
if fetch and not local: 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("@") username, domain = handle.split("@")
domain = Domain.get_remote_domain(domain) domain = Domain.get_remote_domain(domain)
return cls.objects.create( return cls.objects.create(
@ -168,6 +163,10 @@ class Identity(models.Model):
# TODO: Setting # TODO: Setting
return self.data_age > 60 * 24 * 24 return self.data_age > 60 * 24 * 24
@property
def key_id(self):
return self.actor_uri + "#main-key"
### Actor/Webfinger fetching ### ### Actor/Webfinger fetching ###
@classmethod @classmethod

View File

@ -18,7 +18,14 @@ def by_handle_or_404(request, handle, local=True, fetch=False):
domain = domain_instance.domain domain = domain_instance.domain
else: else:
username, domain = handle.split("@", 1) 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: if identity is None:
raise Http404(f"No identity for handle {handle}") raise Http404(f"No identity for handle {handle}")
return identity return identity

View File

@ -24,5 +24,6 @@ async def handle_follow_request(task_handler):
response = await HttpSignature.signed_request( response = await HttpSignature.signed_request(
follow.target.inbox_uri, request, follow.source follow.target.inbox_uri, request, follow.source
) )
print(response) if response.status_code >= 400:
print(response.content) raise ValueError(f"Request error: {response.status_code} {response.content}")
await Follow.objects.filter(pk=follow.pk).aupdate(requested=True)

View File

@ -1,3 +1,5 @@
from asgiref.sync import sync_to_async
from users.models import Follow, Identity from users.models import Follow, Identity
@ -5,14 +7,20 @@ async def handle_inbox_item(task_handler):
type = task_handler.payload["type"].lower() type = task_handler.payload["type"].lower()
if type == "follow": if type == "follow":
await inbox_follow(task_handler.payload) 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": elif type == "undo":
inner_type = task_handler.payload["object"]["type"].lower() inner_type = task_handler.payload["object"]["type"].lower()
if inner_type == "follow": if inner_type == "follow":
await inbox_unfollow(task_handler.payload["object"]) await inbox_unfollow(task_handler.payload["object"])
else: else:
raise ValueError("Cannot undo activity of type {inner_type}") raise ValueError(f"Cannot handle activity of type undo.{inner_type}")
else: 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): async def inbox_follow(payload):
@ -34,3 +42,15 @@ async def inbox_follow(payload):
async def inbox_unfollow(payload): async def inbox_unfollow(payload):
pass 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()

View File

@ -130,8 +130,9 @@ class CreateIdentity(FormView):
def form_valid(self, form): def form_valid(self, form):
username = form.cleaned_data["username"] username = form.cleaned_data["username"]
domain = form.cleaned_data["domain"] domain = form.cleaned_data["domain"]
domain_instance = Domain.get_local_domain(domain)
new_identity = Identity.objects.create( 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, username=username,
domain_id=domain, domain_id=domain,
name=form.cleaned_data["name"], name=form.cleaned_data["name"],
@ -154,13 +155,13 @@ class Actor(View):
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1", "https://w3id.org/security/v1",
], ],
"id": identity.urls.actor.full(), "id": identity.actor_uri,
"type": "Person", "type": "Person",
"inbox": identity.urls.inbox.full(), "inbox": identity.actor_uri + "inbox/",
"preferredUsername": identity.username, "preferredUsername": identity.username,
"publicKey": { "publicKey": {
"id": identity.urls.key.full(), "id": identity.key_id,
"owner": identity.urls.actor.full(), "owner": identity.actor_uri,
"publicKeyPem": identity.public_key, "publicKeyPem": identity.public_key,
}, },
"published": identity.created.strftime("%Y-%m-%dT%H:%M:%SZ"), "published": identity.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
@ -249,7 +250,7 @@ class Webfinger(View):
{ {
"rel": "self", "rel": "self",
"type": "application/activity+json", "type": "application/activity+json",
"href": identity.urls.actor.full(), "href": identity.actor_uri,
}, },
], ],
} }