summaryrefslogtreecommitdiffstats
path: root/users/models
diff options
context:
space:
mode:
Diffstat (limited to 'users/models')
-rw-r--r--users/models/__init__.py2
-rw-r--r--users/models/block.py30
-rw-r--r--users/models/domain.py83
-rw-r--r--users/models/follow.py2
-rw-r--r--users/models/identity.py144
5 files changed, 208 insertions, 53 deletions
diff --git a/users/models/__init__.py b/users/models/__init__.py
index ce69d1d..e1877bc 100644
--- a/users/models/__init__.py
+++ b/users/models/__init__.py
@@ -1,3 +1,5 @@
+from .block import Block # noqa
+from .domain import Domain # noqa
from .follow import Follow # noqa
from .identity import Identity # noqa
from .user import User # noqa
diff --git a/users/models/block.py b/users/models/block.py
new file mode 100644
index 0000000..d312363
--- /dev/null
+++ b/users/models/block.py
@@ -0,0 +1,30 @@
+from django.db import models
+
+
+class Block(models.Model):
+ """
+ When one user (the source) mutes or blocks another (the target)
+ """
+
+ source = models.ForeignKey(
+ "users.Identity",
+ on_delete=models.CASCADE,
+ related_name="outbound_blocks",
+ )
+
+ target = models.ForeignKey(
+ "users.Identity",
+ on_delete=models.CASCADE,
+ related_name="inbound_blocks",
+ )
+
+ # If it is a mute, we will stop delivering any activities from target to
+ # source, but we will still deliver activities from source to target.
+ # A full block (non-mute) stops activities both ways.
+ mute = models.BooleanField()
+
+ expires = models.DateTimeField(blank=True, null=True)
+ 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/domain.py b/users/models/domain.py
new file mode 100644
index 0000000..f503b89
--- /dev/null
+++ b/users/models/domain.py
@@ -0,0 +1,83 @@
+from typing import Optional
+
+from django.db import models
+
+
+class Domain(models.Model):
+ """
+ Represents a domain that a user can have an account on.
+
+ For protocol reasons, if we want to allow custom usernames
+ per domain, each "display" domain (the one in the handle) must either let
+ us serve on it directly, or have a "service" domain that maps
+ to it uniquely that we can serve on that.
+
+ That way, someone coming in with just an Actor URI as their
+ entrypoint can still try to webfinger preferredUsername@actorDomain
+ and we can return an appropriate response.
+
+ It's possible to just have one domain do both jobs, of course.
+ This model also represents _other_ servers' domains, which we treat as
+ display domains for now, until we start doing better probing.
+ """
+
+ domain = models.CharField(max_length=250, primary_key=True)
+ service_domain = models.CharField(
+ max_length=250,
+ null=True,
+ blank=True,
+ db_index=True,
+ unique=True,
+ )
+
+ # If we own this domain
+ local = models.BooleanField()
+
+ # If we have blocked this domain from interacting with us
+ blocked = models.BooleanField(default=False)
+
+ # Domains can be joinable by any user of the instance (as the default one
+ # should)
+ public = models.BooleanField(default=False)
+
+ # Domains can also be linked to one or more users for their private use
+ # This should be display domains ONLY
+ users = models.ManyToManyField("users.User", related_name="domains", blank=True)
+
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
+ @classmethod
+ def get_remote_domain(cls, domain) -> "Domain":
+ try:
+ return cls.objects.get(domain=domain, local=False)
+ except cls.DoesNotExist:
+ return cls.objects.create(domain=domain, local=False)
+
+ @classmethod
+ def get_local_domain(cls, domain) -> Optional["Domain"]:
+ try:
+ return cls.objects.get(
+ models.Q(domain=domain) | models.Q(service_domain=domain)
+ )
+ except cls.DoesNotExist:
+ return None
+
+ @property
+ def uri_domain(self) -> str:
+ if self.service_domain:
+ return self.service_domain
+ return self.domain
+
+ @classmethod
+ def available_for_user(cls, user):
+ """
+ Returns domains that are available for the user to put an identity on
+ """
+ return cls.objects.filter(
+ models.Q(public=True) | models.Q(users__id=user.id),
+ local=True,
+ )
+
+ def __str__(self):
+ return self.domain
diff --git a/users/models/follow.py b/users/models/follow.py
index e134f28..7287900 100644
--- a/users/models/follow.py
+++ b/users/models/follow.py
@@ -3,7 +3,7 @@ from django.db import models
class Follow(models.Model):
"""
- Tracks major events that happen to users
+ When one user (the source) follows other (the target)
"""
source = models.ForeignKey(
diff --git a/users/models/identity.py b/users/models/identity.py
index 3aa4545..b5f9897 100644
--- a/users/models/identity.py
+++ b/users/models/identity.py
@@ -1,6 +1,8 @@
import base64
import uuid
from functools import partial
+from typing import Optional, Tuple
+from urllib.parse import urlparse
import httpx
import urlman
@@ -14,6 +16,7 @@ from django.utils import timezone
from django.utils.http import http_date
from core.ld import canonicalise
+from users.models.domain import Domain
def upload_namer(prefix, instance, filename):
@@ -30,12 +33,26 @@ class Identity(models.Model):
Represents both local and remote Fediverse identities (actors)
"""
- # The handle includes the domain!
- handle = models.CharField(max_length=500, unique=True)
+ # The Actor URI is essentially also a PK - we keep the default numeric
+ # one around as well for making nice URLs etc.
+ actor_uri = models.CharField(max_length=500, blank=True, null=True, unique=True)
+
+ local = models.BooleanField()
+ users = models.ManyToManyField("users.User", related_name="identities")
+
+ username = models.CharField(max_length=500, blank=True, null=True)
+ # Must be a display domain if present
+ domain = models.ForeignKey(
+ "users.Domain",
+ blank=True,
+ null=True,
+ on_delete=models.PROTECT,
+ )
+
name = models.CharField(max_length=500, blank=True, null=True)
summary = models.TextField(blank=True, null=True)
+ manually_approves_followers = models.BooleanField(blank=True, null=True)
- actor_uri = models.CharField(max_length=500, blank=True, null=True, db_index=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)
@@ -49,9 +66,6 @@ class Identity(models.Model):
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)
@@ -62,36 +76,37 @@ class Identity(models.Model):
class Meta:
verbose_name_plural = "identities"
+ unique_together = [("username", "domain")]
@classmethod
- def by_handle(cls, handle, create=True):
+ 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("@")
try:
- return cls.objects.filter(handle=handle).get()
+ if local:
+ return cls.objects.get(username=username, domain_id=domain, local=True)
+ else:
+ return cls.objects.get(username=username, domain_id=domain)
except cls.DoesNotExist:
- if create:
+ if fetch and not local:
return cls.objects.create(handle=handle, local=False)
return None
@classmethod
- def by_actor_uri(cls, uri):
+ def by_actor_uri(cls, uri, create=False):
try:
- cls.objects.filter(actor_uri=uri)
+ return cls.objects.get(actor_uri=uri)
except cls.DoesNotExist:
+ if create:
+ return cls.objects.create(actor_uri=uri, local=False)
return None
@property
- def short_handle(self):
- if self.handle.endswith("@" + settings.DEFAULT_DOMAIN):
- return self.handle.split("@", 1)[0]
- return self.handle
-
- @property
- def domain(self):
- return self.handle.split("@", 1)[1]
+ def handle(self):
+ return f"{self.username}@{self.domain_id}"
@property
def data_age(self) -> float:
@@ -105,6 +120,8 @@ class Identity(models.Model):
return (timezone.now() - self.fetched).total_seconds()
def generate_keypair(self):
+ if not self.local:
+ raise ValueError("Cannot generate keypair for remote user")
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
@@ -120,44 +137,39 @@ 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:
+ @classmethod
+ async def fetch_webfinger(cls, handle: str) -> Tuple[Optional[str], Optional[str]]:
+ """
+ Given a username@domain handle, returns a tuple of
+ (actor uri, canonical handle) or None, None if it does not resolve.
+ """
+ domain = handle.split("@")[1]
async with httpx.AsyncClient() as client:
response = await client.get(
- f"https://{self.domain}/.well-known/webfinger?resource=acct:{self.handle}",
+ f"https://{domain}/.well-known/webfinger?resource=acct:{handle}",
headers={"Accept": "application/json"},
follow_redirects=True,
)
if response.status_code >= 400:
- return False
+ return None, None
data = response.json()
+ if data["subject"].startswith("acct:"):
+ data["subject"] = data["subject"][5:]
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
+ return link["href"], data["subject"]
+ return None, None
async def fetch_actor(self) -> bool:
+ """
+ Fetches the user's actor information, as well as their domain from
+ webfinger if it's available.
+ """
+ if self.local:
+ raise ValueError("Cannot fetch local identities")
async with httpx.AsyncClient() as client:
response = await client.get(
self.actor_uri,
@@ -166,29 +178,48 @@ class Identity(models.Model):
)
if response.status_code >= 400:
return False
- document = canonicalise(response.json())
+ document = canonicalise(response.json(), include_security=True)
self.name = document.get("name")
+ self.profile_uri = document.get("url")
self.inbox_uri = document.get("inbox")
self.outbox_uri = document.get("outbox")
self.summary = document.get("summary")
+ self.username = document.get("preferredUsername")
self.manually_approves_followers = document.get(
"as:manuallyApprovesFollowers"
)
self.public_key = document.get("publicKey", {}).get("publicKeyPem")
self.icon_uri = document.get("icon", {}).get("url")
self.image_uri = document.get("image", {}).get("url")
+ # Now go do webfinger with that info to see if we can get a canonical domain
+ actor_url_parts = urlparse(self.actor_uri)
+ get_domain = sync_to_async(Domain.get_remote_domain)
+ if self.username:
+ webfinger_actor, webfinger_handle = await self.fetch_webfinger(
+ f"{self.username}@{actor_url_parts.hostname}"
+ )
+ if webfinger_handle:
+ webfinger_username, webfinger_domain = webfinger_handle.split("@")
+ self.username = webfinger_username
+ self.domain = await get_domain(webfinger_domain)
+ else:
+ self.domain = await get_domain(actor_url_parts.hostname)
+ else:
+ self.domain = await get_domain(actor_url_parts.hostname)
+ self.fetched = timezone.now()
+ await sync_to_async(self.save)()
return True
def sign(self, cleartext: str) -> str:
if not self.private_key:
raise ValueError("Cannot sign - no private key")
private_key = serialization.load_pem_private_key(
- self.private_key,
+ self.private_key.encode("ascii"),
password=None,
)
return base64.b64encode(
private_key.sign(
- cleartext,
+ cleartext.encode("utf8"),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
@@ -199,12 +230,13 @@ class Identity(models.Model):
def verify_signature(self, crypttext: str, cleartext: str) -> bool:
if not self.public_key:
- raise ValueError("Cannot verify - no private key")
- public_key = serialization.load_pem_public_key(self.public_key)
+ raise ValueError("Cannot verify - no public key")
+ public_key = serialization.load_pem_public_key(self.public_key.encode("ascii"))
+ print("sig??", crypttext, cleartext)
try:
public_key.verify(
- crypttext,
- cleartext,
+ crypttext.encode("utf8"),
+ cleartext.encode("utf8"),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
@@ -250,10 +282,18 @@ class Identity(models.Model):
pass
def __str__(self):
- return self.name or self.handle
+ return self.handle or self.actor_uri
class urls(urlman.Urls):
- view = "/@{self.short_handle}/"
+ view = "/@{self.username}@{self.domain_id}/"
+ view_short = "/@{self.username}/"
actor = "{view}actor/"
inbox = "{actor}inbox/"
+ outbox = "{actor}outbox/"
activate = "{view}activate/"
+
+ def get_scheme(self, url):
+ return "https"
+
+ def get_hostname(self, url):
+ return self.instance.domain.uri_domain