From e44a321ec53bc84b5986ac0371b4122201fa3a5a Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 5 Nov 2022 17:51:54 -0600 Subject: Get Actor fetching and parsing working --- users/migrations/0001_initial.py | 67 ++++++++++++++--- users/models/__init__.py | 1 + users/models/follow.py | 23 ++++++ users/models/identity.py | 150 +++++++++++++++++++++++++++++++++++++-- users/models/user.py | 6 ++ users/views/identity.py | 16 ++++- 6 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 users/models/follow.py (limited to 'users') diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index e258d1b..5f9eacb 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-05 19:15 +# Generated by Django 4.1.3 on 2022-11-05 23:50 import functools @@ -96,32 +96,50 @@ class Migration(migrations.Migration): ), ("handle", models.CharField(max_length=500, unique=True)), ("name", models.CharField(blank=True, max_length=500, null=True)), - ("bio", models.TextField(blank=True, null=True)), + ("summary", models.TextField(blank=True, null=True)), + ("actor_uri", models.CharField(blank=True, max_length=500, null=True)), ( - "profile_image", + "profile_uri", + models.CharField(blank=True, max_length=500, null=True), + ), + ("inbox_uri", models.CharField(blank=True, max_length=500, null=True)), + ("outbox_uri", models.CharField(blank=True, max_length=500, null=True)), + ("icon_uri", models.CharField(blank=True, max_length=500, null=True)), + ("image_uri", models.CharField(blank=True, max_length=500, null=True)), + ( + "icon", models.ImageField( + blank=True, + null=True, upload_to=functools.partial( users.models.identity.upload_namer, *("profile_images",), **{}, - ) + ), ), ), ( - "background_image", + "image", models.ImageField( + blank=True, + null=True, upload_to=functools.partial( users.models.identity.upload_namer, *("background_images",), **{}, - ) + ), ), ), ("local", models.BooleanField()), - ("private_key", models.BinaryField(blank=True, null=True)), - ("public_key", models.BinaryField(blank=True, null=True)), + ( + "manually_approves_followers", + models.BooleanField(blank=True, null=True), + ), + ("private_key", models.TextField(blank=True, null=True)), + ("public_key", models.TextField(blank=True, null=True)), ("created", models.DateTimeField(auto_now_add=True)), ("updated", models.DateTimeField(auto_now=True)), + ("fetched", models.DateTimeField(blank=True, null=True)), ("deleted", models.DateTimeField(blank=True, null=True)), ( "users", @@ -131,4 +149,37 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="Follow", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("note", models.TextField(blank=True, null=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="outbound_follows", + to="users.identity", + ), + ), + ( + "target", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="inbound_follows", + to="users.identity", + ), + ), + ], + ), ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 7032a81..ce69d1d 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -1,3 +1,4 @@ +from .follow import Follow # noqa from .identity import Identity # noqa from .user import User # noqa from .user_event import UserEvent # noqa diff --git a/users/models/follow.py b/users/models/follow.py new file mode 100644 index 0000000..e134f28 --- /dev/null +++ b/users/models/follow.py @@ -0,0 +1,23 @@ +from django.db import models + + +class Follow(models.Model): + """ + Tracks major events that happen to users + """ + + source = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="outbound_follows", + ) + target = models.ForeignKey( + "users.Identity", + on_delete=models.CASCADE, + related_name="inbound_follows", + ) + + note = models.TextField(blank=True, null=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) diff --git a/users/models/identity.py b/users/models/identity.py index 495b4a4..c20ef60 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -2,12 +2,17 @@ import base64 import uuid from functools import partial +import httpx import urlman -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa +from asgiref.sync import sync_to_async +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa from django.conf import settings from django.db import models from django.utils import timezone +from django.utils.http import http_date + +from core.ld import LDDocument def upload_namer(prefix, instance, filename): @@ -27,20 +32,31 @@ class Identity(models.Model): # The handle includes the domain! handle = models.CharField(max_length=500, unique=True) name = models.CharField(max_length=500, blank=True, null=True) - bio = models.TextField(blank=True, null=True) + summary = models.TextField(blank=True, null=True) + + actor_uri = models.CharField(max_length=500, blank=True, null=True) + profile_uri = models.CharField(max_length=500, blank=True, null=True) + inbox_uri = models.CharField(max_length=500, blank=True, null=True) + outbox_uri = models.CharField(max_length=500, blank=True, null=True) + icon_uri = models.CharField(max_length=500, blank=True, null=True) + image_uri = models.CharField(max_length=500, blank=True, null=True) - profile_image = models.ImageField(upload_to=partial(upload_namer, "profile_images")) - background_image = models.ImageField( - upload_to=partial(upload_namer, "background_images") + icon = models.ImageField( + upload_to=partial(upload_namer, "profile_images"), blank=True, null=True + ) + image = models.ImageField( + upload_to=partial(upload_namer, "background_images"), blank=True, null=True ) local = models.BooleanField() users = models.ManyToManyField("users.User", related_name="identities") + manually_approves_followers = models.BooleanField(blank=True, null=True) private_key = models.TextField(null=True, blank=True) public_key = models.TextField(null=True, blank=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + fetched = models.DateTimeField(null=True, blank=True) deleted = models.DateTimeField(null=True, blank=True) @property @@ -69,6 +85,128 @@ class Identity(models.Model): ) self.save() + async def fetch_details(self): + if self.local: + raise ValueError("Cannot fetch local identities") + self.actor_uri = None + self.inbox_uri = None + self.profile_uri = None + # Go knock on webfinger and see what their address is + await self.fetch_webfinger() + # Fetch actor JSON + if self.actor_uri: + await self.fetch_actor() + self.fetched = timezone.now() + await sync_to_async(self.save)() + + async def fetch_webfinger(self) -> bool: + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://{self.domain}/.well-known/webfinger?resource=acct:{self.handle}", + headers={"Accept": "application/json"}, + ) + if response.status_code >= 400: + return False + data = response.json() + for link in data["links"]: + if ( + link.get("type") == "application/activity+json" + and link.get("rel") == "self" + ): + self.actor_uri = link["href"] + elif ( + link.get("type") == "text/html" + and link.get("rel") == "http://webfinger.net/rel/profile-page" + ): + self.profile_uri = link["href"] + return True + + async def fetch_actor(self) -> bool: + async with httpx.AsyncClient() as client: + response = await client.get( + self.actor_uri, + headers={"Accept": "application/json"}, + ) + if response.status_code >= 400: + return False + data = response.json() + document = LDDocument(data) + for person in document.by_type( + "https://www.w3.org/ns/activitystreams#Person" + ): + self.name = person.get("https://www.w3.org/ns/activitystreams#name") + self.summary = person.get( + "https://www.w3.org/ns/activitystreams#summary" + ) + self.inbox_uri = person.get("http://www.w3.org/ns/ldp#inbox") + self.outbox_uri = person.get( + "https://www.w3.org/ns/activitystreams#outbox" + ) + self.manually_approves_followers = person.get( + "https://www.w3.org/ns/activitystreams#manuallyApprovesFollowers'" + ) + self.private_key = person.get( + "https://w3id.org/security#publicKey" + ).get("https://w3id.org/security#publicKeyPem") + icon = person.get("https://www.w3.org/ns/activitystreams#icon") + if icon: + self.icon_uri = icon.get( + "https://www.w3.org/ns/activitystreams#url" + ) + image = person.get("https://www.w3.org/ns/activitystreams#image") + if image: + self.image_uri = image.get( + "https://www.w3.org/ns/activitystreams#url" + ) + return True + + async def signed_request(self, host, method, path, document): + """ + Delivers the document to the specified host, method, path and signed + as this user. + """ + private_key = serialization.load_pem_private_key( + self.private_key, + password=None, + ) + date_string = http_date(timezone.now().timestamp()) + headers = { + "(request-target)": f"{method} {path}", + "Host": host, + "Date": date_string, + } + headers_string = " ".join(headers.keys()) + signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items()) + signature = base64.b64encode( + private_key.sign( + signed_string, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.MAX_LENGTH, + ), + hashes.SHA256(), + ) + ) + del headers["(request-target)"] + headers[ + "Signature" + ] = f'keyId="https://{settings.DEFAULT_DOMAIN}{self.urls.actor}",headers="{headers_string}",signature="{signature}"' + async with httpx.AsyncClient() as client: + return await client.request( + method, + "https://{host}{path}", + headers=headers, + data=document, + ) + + def validate_signature(self, request): + """ + Attempts to validate the signature on an incoming request. + Returns False if the signature is invalid, None if it cannot be verified + as we do not have the key locally, or the name of the actor if it is valid. + """ + pass + def __str__(self): return self.name or self.handle diff --git a/users/models/user.py b/users/models/user.py index de51380..6435bf5 100644 --- a/users/models/user.py +++ b/users/models/user.py @@ -56,3 +56,9 @@ class User(AbstractBaseUser): @property def is_staff(self): return self.admin + + def has_module_perms(self, module): + return self.admin + + def has_perm(self, perm): + return self.admin diff --git a/users/views/identity.py b/users/views/identity.py index 63b7fb8..9f2a7f9 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -4,6 +4,7 @@ from django.contrib.auth.decorators import login_required from django.http import Http404, JsonResponse from django.shortcuts import redirect from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt from django.views.generic import FormView, TemplateView, View from core.forms import FormHelper @@ -88,7 +89,7 @@ class Actor(View): ], "id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}", "type": "Person", - "preferredUsername": "alice", + "preferredUsername": identity.short_handle, "inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}", "publicKey": { "id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key", @@ -99,6 +100,19 @@ class Actor(View): ) +@method_decorator(csrf_exempt, name="dispatch") +class Inbox(View): + """ + AP Inbox endpoint + """ + + def post(self, request, handle): + # Validate the signature + signature = request.META.get("HTTP_SIGNATURE") + print(signature) + print(request.body) + + class Webfinger(View): """ Services webfinger requests -- cgit v1.2.3