diff options
Diffstat (limited to 'users/models')
-rw-r--r-- | users/models/__init__.py | 1 | ||||
-rw-r--r-- | users/models/follow.py | 23 | ||||
-rw-r--r-- | users/models/identity.py | 150 | ||||
-rw-r--r-- | users/models/user.py | 6 |
4 files changed, 174 insertions, 6 deletions
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 |