summaryrefslogtreecommitdiffstats
path: root/users
diff options
context:
space:
mode:
Diffstat (limited to 'users')
-rw-r--r--users/migrations/0001_initial.py67
-rw-r--r--users/models/__init__.py1
-rw-r--r--users/models/follow.py23
-rw-r--r--users/models/identity.py150
-rw-r--r--users/models/user.py6
-rw-r--r--users/views/identity.py16
6 files changed, 248 insertions, 15 deletions
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