summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--core/context.py4
-rw-r--r--core/ld.py11
-rw-r--r--core/signatures.py48
-rw-r--r--miniq/migrations/0001_initial.py9
-rw-r--r--miniq/views.py7
-rw-r--r--static/css/style.css3
-rw-r--r--statuses/migrations/0001_initial.py2
-rw-r--r--statuses/models/status.py2
-rw-r--r--takahe/settings.py3
-rw-r--r--templates/base.html8
-rw-r--r--templates/identity/select.html2
-rw-r--r--templates/identity/view.html2
-rw-r--r--templates/statuses/_status.html2
-rw-r--r--users/admin.py7
-rw-r--r--users/decorators.py17
-rw-r--r--users/middleware.py24
-rw-r--r--users/migrations/0001_initial.py93
-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
-rw-r--r--users/shortcuts.py27
-rw-r--r--users/views/identity.py153
24 files changed, 517 insertions, 168 deletions
diff --git a/core/context.py b/core/context.py
index 38a268c..026ac11 100644
--- a/core/context.py
+++ b/core/context.py
@@ -2,4 +2,6 @@ from django.conf import settings
def config_context(request):
- return {"config": {"site_name": settings.SITE_NAME}}
+ return {
+ "config": {"site_name": settings.SITE_NAME},
+ }
diff --git a/core/ld.py b/core/ld.py
index 38e436a..28ff65a 100644
--- a/core/ld.py
+++ b/core/ld.py
@@ -252,7 +252,7 @@ def builtin_document_loader(url: str, options={}):
)
-def canonicalise(json_data):
+def canonicalise(json_data, include_security=False):
"""
Given an ActivityPub JSON-LD document, round-trips it through the LD
systems to end up in a canonicalised, compacted format.
@@ -264,5 +264,12 @@ def canonicalise(json_data):
raise ValueError("Pass decoded JSON data into LDDocument")
return jsonld.compact(
jsonld.expand(json_data),
- ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
+ (
+ [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ ]
+ if include_security
+ else "https://www.w3.org/ns/activitystreams"
+ ),
)
diff --git a/core/signatures.py b/core/signatures.py
new file mode 100644
index 0000000..bcacb68
--- /dev/null
+++ b/core/signatures.py
@@ -0,0 +1,48 @@
+import base64
+from typing import Any, Dict, List
+
+from cryptography.hazmat.primitives import hashes
+from django.http import HttpRequest
+
+
+class HttpSignature:
+ """
+ Allows for calculation and verification of HTTP signatures
+ """
+
+ @classmethod
+ def calculate_digest(cls, data, algorithm="sha-256") -> str:
+ """
+ Calculates the digest header value for a given HTTP body
+ """
+ if algorithm == "sha-256":
+ digest = hashes.Hash(hashes.SHA256())
+ digest.update(data)
+ return "SHA-256=" + base64.b64encode(digest.finalize()).decode("ascii")
+ else:
+ raise ValueError(f"Unknown digest algorithm {algorithm}")
+
+ @classmethod
+ def headers_from_request(cls, request: HttpRequest, header_names: List[str]) -> str:
+ """
+ Creates the to-be-signed header payload from a Django request"""
+ headers = {}
+ for header_name in header_names:
+ if header_name == "(request-target)":
+ value = f"post {request.path}"
+ elif header_name == "content-type":
+ value = request.META["CONTENT_TYPE"]
+ else:
+ value = request.META[f"HTTP_{header_name.upper()}"]
+ headers[header_name] = value
+ return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items())
+
+ @classmethod
+ def parse_signature(cls, signature) -> Dict[str, Any]:
+ signature_details = {}
+ for item in signature.split(","):
+ name, value = item.split("=", 1)
+ value = value.strip('"')
+ signature_details[name.lower()] = value
+ signature_details["headers"] = signature_details["headers"].split()
+ return signature_details
diff --git a/miniq/migrations/0001_initial.py b/miniq/migrations/0001_initial.py
index 6775ff3..32c5d53 100644
--- a/miniq/migrations/0001_initial.py
+++ b/miniq/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.1.3 on 2022-11-06 03:59
+# Generated by Django 4.1.3 on 2022-11-06 19:58
from django.db import migrations, models
@@ -22,7 +22,12 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
- ("type", models.CharField(max_length=500)),
+ (
+ "type",
+ models.CharField(
+ choices=[("identity_fetch", "Identity Fetch")], max_length=500
+ ),
+ ),
("priority", models.IntegerField(default=0)),
("subject", models.TextField()),
("payload", models.JSONField(blank=True, null=True)),
diff --git a/miniq/views.py b/miniq/views.py
index 12da7cd..21275f8 100644
--- a/miniq/views.py
+++ b/miniq/views.py
@@ -64,5 +64,8 @@ class QueueProcessor(View):
await task.fail(f"{e}\n\n" + traceback.format_exc())
async def handle_identity_fetch(self, subject, payload):
- identity = await sync_to_async(Identity.by_handle)(subject)
- await identity.fetch_details()
+ # Get the actor URI via webfinger
+ actor_uri, handle = await Identity.fetch_webfinger(subject)
+ # Get or create the identity, then fetch
+ identity = await sync_to_async(Identity.by_actor_uri)(actor_uri, create=True)
+ await identity.fetch_actor()
diff --git a/static/css/style.css b/static/css/style.css
index 7a3b20a..511d301 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -229,7 +229,8 @@ form .help-block {
padding: 4px 0 0 0;
}
-form input {
+form input,
+form select {
width: 100%;
padding: 4px 6px;
background: var(--color-bg1);
diff --git a/statuses/migrations/0001_initial.py b/statuses/migrations/0001_initial.py
index 58a7d29..933c526 100644
--- a/statuses/migrations/0001_initial.py
+++ b/statuses/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.1.3 on 2022-11-05 23:50
+# Generated by Django 4.1.3 on 2022-11-06 19:58
import django.db.models.deletion
from django.db import migrations, models
diff --git a/statuses/models/status.py b/statuses/models/status.py
index ac40806..bfc8eb9 100644
--- a/statuses/models/status.py
+++ b/statuses/models/status.py
@@ -36,4 +36,4 @@ class Status(models.Model):
)
class urls(urlman.Urls):
- view = "{self.identity.urls.view}{self.id}/"
+ view = "{self.identity.urls.view}statuses/{self.id}/"
diff --git a/takahe/settings.py b/takahe/settings.py
index 26fd705..c3c8d38 100644
--- a/takahe/settings.py
+++ b/takahe/settings.py
@@ -10,7 +10,7 @@ SECRET_KEY = "insecure_secret"
DEBUG = True
ALLOWED_HOSTS = ["*"]
-
+CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"]
# Application definition
@@ -36,6 +36,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "users.middleware.IdentityMiddleware",
]
ROOT_URLCONF = "takahe.urls"
diff --git a/templates/base.html b/templates/base.html
index af2887f..2ff0f15 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -26,10 +26,12 @@
<li>
{% if user.is_authenticated %}
<a href="/identity/select/">
- {% if user.icon_uri %}
- <img src="{{ user.icon_uri }}" width="32">
+ {% if not request.identity %}
+ <img src="{% static "img/unknown-icon-128.png" %}" width="32" title="No identity selected">
+ {% elif request.identity.icon_uri %}
+ <img src="{{ request.identity.icon_uri }}" width="32" title="{{ request.identity.handle }}">
{% else %}
- <img src="{% static "img/unknown-icon-128.png" %}" width="32">
+ <img src="{% static "img/unknown-icon-128.png" %}" width="32" title="{{ request.identity.handle }}">
{% endif %}
</a>
{% else %}
diff --git a/templates/identity/select.html b/templates/identity/select.html
index dae1ca1..ea4065c 100644
--- a/templates/identity/select.html
+++ b/templates/identity/select.html
@@ -14,7 +14,7 @@
<img src="{% static "img/unknown-icon-128.png" %}" width="32">
{% endif %}
{{ identity }}
- <small>@{{ identity.short_handle }}</small>
+ <small>@{{ identity.handle }}</small>
</a>
{% empty %}
<p class="option empty">You have no identities.</p>
diff --git a/templates/identity/view.html b/templates/identity/view.html
index 2a82478..ffb76db 100644
--- a/templates/identity/view.html
+++ b/templates/identity/view.html
@@ -10,7 +10,7 @@
{% else %}
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
{% endif %}
- {{ identity }} <small>{{ identity.handle }}</small>
+ {{ identity }} <small>@{{ identity.handle }}</small>
</h1>
{% if not identity.local %}
diff --git a/templates/statuses/_status.html b/templates/statuses/_status.html
index b89909a..b501abc 100644
--- a/templates/statuses/_status.html
+++ b/templates/statuses/_status.html
@@ -2,7 +2,7 @@
<h3 class="author">
<a href="{{ status.identity.urls.view }}">
{{ status.identity }}
- <small>{{ status.identity.short_handle }}</small>
+ <small>{{ status.identity.handle }}</small>
</a>
</h3>
<time>
diff --git a/users/admin.py b/users/admin.py
index e5db9d1..5672876 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -1,6 +1,11 @@
from django.contrib import admin
-from users.models import Identity, User, UserEvent
+from users.models import Domain, Identity, User, UserEvent
+
+
+@admin.register(Domain)
+class DomainAdmin(admin.ModelAdmin):
+ list_display = ["domain", "service_domain", "local", "blocked", "public"]
@admin.register(User)
diff --git a/users/decorators.py b/users/decorators.py
index 77d633a..d373692 100644
--- a/users/decorators.py
+++ b/users/decorators.py
@@ -3,8 +3,6 @@ from functools import wraps
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect
-from users.models import Identity
-
def identity_required(function):
"""
@@ -16,24 +14,15 @@ def identity_required(function):
# They do have to be logged in
if not request.user.is_authenticated:
return redirect_to_login(next=request.get_full_path())
- # Try to retrieve their active identity
- identity_id = request.session.get("identity_id")
- if not identity_id:
- identity = None
- else:
- try:
- identity = Identity.objects.get(id=identity_id)
- except Identity.DoesNotExist:
- identity = None
# If there's no active one, try to auto-select one
- if identity is None:
+ if request.identity is None:
possible_identities = list(request.user.identities.all())
if len(possible_identities) != 1:
# OK, send them to the identity selection page to select/create one
return HttpResponseRedirect("/identity/select/")
identity = possible_identities[0]
- request.identity = identity
- request.session["identity_id"] = identity.pk
+ request.session["identity_id"] = identity.pk
+ request.identity = identity
return function(request, *args, **kwargs)
return inner
diff --git a/users/middleware.py b/users/middleware.py
new file mode 100644
index 0000000..aa22178
--- /dev/null
+++ b/users/middleware.py
@@ -0,0 +1,24 @@
+from users.models import Identity
+
+
+class IdentityMiddleware:
+ """
+ Adds a request.identity object which is either the current session's
+ identity, or None if they have not picked one yet/it's invalid.
+ """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ identity_id = request.session.get("identity_id")
+ if not identity_id:
+ request.identity = None
+ else:
+ try:
+ request.identity = Identity.objects.get(id=identity_id)
+ except Identity.DoesNotExist:
+ request.identity = None
+
+ response = self.get_response(request)
+ return response
diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py
index 5f9eacb..364daaa 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 23:50
+# Generated by Django 4.1.3 on 2022-11-06 19:58
import functools
@@ -48,6 +48,30 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
+ name="Domain",
+ fields=[
+ (
+ "domain",
+ models.CharField(max_length=250, primary_key=True, serialize=False),
+ ),
+ (
+ "service_domain",
+ models.CharField(blank=True, max_length=250, null=True),
+ ),
+ ("local", models.BooleanField()),
+ ("blocked", models.BooleanField(default=False)),
+ ("public", models.BooleanField()),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ (
+ "users",
+ models.ManyToManyField(
+ blank=True, related_name="domains", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
name="UserEvent",
fields=[
(
@@ -94,10 +118,20 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
- ("handle", models.CharField(max_length=500, unique=True)),
+ (
+ "actor_uri",
+ models.CharField(
+ blank=True, max_length=500, null=True, unique=True
+ ),
+ ),
+ ("local", models.BooleanField()),
+ ("username", models.CharField(blank=True, max_length=500, null=True)),
("name", models.CharField(blank=True, max_length=500, null=True)),
("summary", models.TextField(blank=True, null=True)),
- ("actor_uri", models.CharField(blank=True, max_length=500, null=True)),
+ (
+ "manually_approves_followers",
+ models.BooleanField(blank=True, null=True),
+ ),
(
"profile_uri",
models.CharField(blank=True, max_length=500, null=True),
@@ -130,11 +164,6 @@ class Migration(migrations.Migration):
),
),
),
- ("local", models.BooleanField()),
- (
- "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)),
@@ -142,12 +171,25 @@ class Migration(migrations.Migration):
("fetched", models.DateTimeField(blank=True, null=True)),
("deleted", models.DateTimeField(blank=True, null=True)),
(
+ "domain",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="users.domain",
+ ),
+ ),
+ (
"users",
models.ManyToManyField(
related_name="identities", to=settings.AUTH_USER_MODEL
),
),
],
+ options={
+ "verbose_name_plural": "identities",
+ "unique_together": {("username", "domain")},
+ },
),
migrations.CreateModel(
name="Follow",
@@ -182,4 +224,39 @@ class Migration(migrations.Migration):
),
],
),
+ migrations.CreateModel(
+ name="Block",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("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)),
+ (
+ "source",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="outbound_blocks",
+ to="users.identity",
+ ),
+ ),
+ (
+ "target",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="inbound_blocks",
+ to="users.identity",
+ ),
+ ),
+ ],
+ ),
]
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
diff --git a/users/shortcuts.py b/users/shortcuts.py
index 0e00404..167f178 100644
--- a/users/shortcuts.py
+++ b/users/shortcuts.py
@@ -1,7 +1,7 @@
-from django.conf import settings
+from django.http import Http404
from django.shortcuts import get_object_or_404
-from users.models import Identity
+from users.models import Domain, Identity
def by_handle_or_404(request, handle, local=True):
@@ -9,10 +9,25 @@ def by_handle_or_404(request, handle, local=True):
Retrieves an Identity by its long or short handle.
Domain-sensitive, so it will understand short handles on alternate domains.
"""
- # TODO: Domain sensitivity
if "@" not in handle:
- handle += "@" + settings.DEFAULT_DOMAIN
+ if "HTTP_HOST" not in request.META:
+ raise Http404("No hostname available")
+ username = handle
+ domain_instance = Domain.get_local_domain(request.META["HTTP_HOST"])
+ if domain_instance is None:
+ raise Http404("No matching domains found")
+ domain = domain_instance.domain
+ else:
+ username, domain = handle.split("@", 1)
if local:
- return get_object_or_404(Identity.objects.filter(local=True), handle=handle)
+ return get_object_or_404(
+ Identity.objects.filter(local=True),
+ username=username,
+ domain_id=domain,
+ )
else:
- return get_object_or_404(Identity, handle=handle)
+ return get_object_or_404(
+ Identity,
+ username=username,
+ domain_id=domain,
+ )
diff --git a/users/views/identity.py b/users/views/identity.py
index d8f241f..1beef2a 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -1,8 +1,7 @@
-import base64
import json
import string
-from cryptography.hazmat.primitives import hashes
+from asgiref.sync import async_to_sync
from django import forms
from django.conf import settings
from django.contrib.auth.decorators import login_required
@@ -14,8 +13,9 @@ from django.views.generic import FormView, TemplateView, View
from core.forms import FormHelper
from core.ld import canonicalise
+from core.signatures import HttpSignature
from miniq.models import Task
-from users.models import Identity
+from users.models import Domain, Identity
from users.shortcuts import by_handle_or_404
@@ -24,7 +24,7 @@ class ViewIdentity(TemplateView):
template_name = "identity/view.html"
def get_context_data(self, handle):
- identity = Identity.by_handle(handle=handle)
+ identity = by_handle_or_404(self.request, handle, local=False)
statuses = identity.statuses.all()[:100]
if identity.data_age > settings.IDENTITY_MAX_AGE:
Task.submit("identity_fetch", identity.handle)
@@ -65,36 +65,49 @@ class CreateIdentity(FormView):
template_name = "identity/create.html"
class form_class(forms.Form):
- handle = forms.CharField()
+ username = forms.CharField()
name = forms.CharField()
helper = FormHelper(submit_text="Create")
- def clean_handle(self):
+ def __init__(self, user, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["domain"] = forms.ChoiceField(
+ choices=[
+ (domain.domain, domain.domain)
+ for domain in Domain.available_for_user(user)
+ ]
+ )
+
+ def clean_username(self):
# Remove any leading @
- value = self.cleaned_data["handle"].lstrip("@")
+ value = self.cleaned_data["username"].lstrip("@")
# Validate it's all ascii characters
for character in value:
if character not in string.ascii_letters + string.digits + "_-":
raise forms.ValidationError(
"Only the letters a-z, numbers 0-9, dashes and underscores are allowed."
)
- # Don't allow custom domains here quite yet
- if "@" in value:
- raise forms.ValidationError(
- "You are not allowed an @ sign in your handle."
- )
- # Ensure there is a domain on the end
- if "@" not in value:
- value += "@" + settings.DEFAULT_DOMAIN
- # Check for existing users
- if Identity.objects.filter(handle=value).exists():
- raise forms.ValidationError("This handle is already taken")
return value
+ def clean(self):
+ # Check for existing users
+ username = self.cleaned_data["username"]
+ domain = self.cleaned_data["domain"]
+ if Identity.objects.filter(username=username, domain=domain).exists():
+ raise forms.ValidationError(f"{username}@{domain} is already taken")
+
+ def get_form(self):
+ form_class = self.get_form_class()
+ return form_class(user=self.request.user, **self.get_form_kwargs())
+
def form_valid(self, form):
+ username = form.cleaned_data["username"]
+ domain = form.cleaned_data["domain"]
new_identity = Identity.objects.create(
- handle=form.cleaned_data["handle"],
+ actor_uri=f"https://{domain}/@{username}/actor/",
+ username=username,
+ domain_id=domain,
name=form.cleaned_data["name"],
local=True,
)
@@ -110,23 +123,28 @@ class Actor(View):
def get(self, request, handle):
identity = by_handle_or_404(self.request, handle)
- return JsonResponse(
- {
- "@context": [
- "https://www.w3.org/ns/activitystreams",
- "https://w3id.org/security/v1",
- ],
- "id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
- "type": "Person",
- "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",
- "owner": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
- "publicKeyPem": identity.public_key,
- },
- }
- )
+ response = {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ ],
+ "id": identity.urls.actor.full(),
+ "type": "Person",
+ "inbox": identity.urls.inbox.full(),
+ "preferredUsername": identity.username,
+ "publicKey": {
+ "id": identity.urls.actor.full() + "#main-key",
+ "owner": identity.urls.actor.full(),
+ "publicKeyPem": identity.public_key,
+ },
+ "published": identity.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "url": identity.urls.view_short.full(),
+ }
+ if identity.name:
+ response["name"] = identity.name
+ if identity.summary:
+ response["summary"] = identity.summary
+ return JsonResponse(canonicalise(response, include_security=True))
@method_decorator(csrf_exempt, name="dispatch")
@@ -136,48 +154,45 @@ class Inbox(View):
"""
def post(self, request, handle):
+ # Verify body digest
+ if "HTTP_DIGEST" in request.META:
+ expected_digest = HttpSignature.calculate_digest(request.body)
+ if request.META["HTTP_DIGEST"] != expected_digest:
+ print("Bad digest")
+ return HttpResponseBadRequest()
+ # Get the signature details
if "HTTP_SIGNATURE" not in request.META:
print("No signature")
return HttpResponseBadRequest()
- # Split apart signature
- signature_details = {}
- for item in request.META["HTTP_SIGNATURE"].split(","):
- name, value = item.split("=", 1)
- value = value.strip('"')
- signature_details[name] = value
+ signature_details = HttpSignature.parse_signature(
+ request.META["HTTP_SIGNATURE"]
+ )
# Reject unknown algorithms
if signature_details["algorithm"] != "rsa-sha256":
print("Unknown algorithm")
return HttpResponseBadRequest()
- # Calculate body digest
- if "HTTP_DIGEST" in request.META:
- digest = hashes.Hash(hashes.SHA256())
- digest.update(request.body)
- digest_header = "SHA-256=" + base64.b64encode(digest.finalize()).decode(
- "ascii"
- )
- if request.META["HTTP_DIGEST"] != digest_header:
- print("Bad digest")
- return HttpResponseBadRequest()
# Create the signature payload
- headers = {}
- for header_name in signature_details["headers"].split():
- if header_name == "(request-target)":
- value = f"post {request.path}"
- elif header_name == "content-type":
- value = request.META["CONTENT_TYPE"]
- else:
- value = request.META[f"HTTP_{header_name.upper()}"]
- headers[header_name] = value
- signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items())
+ headers_string = HttpSignature.headers_from_request(
+ request, signature_details["headers"]
+ )
# Load the LD
document = canonicalise(json.loads(request.body))
+ print(signature_details)
+ print(headers_string)
print(document)
# Find the Identity by the actor on the incoming item
- identity = Identity.by_actor_uri(document["actor"])
- if not identity.verify_signature(signature_details["signature"], signed_string):
+ identity = Identity.by_actor_uri(document["actor"], create=True)
+ if not identity.public_key:
+ # See if we can fetch it right now
+ async_to_sync(identity.fetch_actor)()
+ if not identity.public_key:
+ print("Cannot retrieve actor")
+ return HttpResponseBadRequest("Cannot retrieve actor")
+ if not identity.verify_signature(
+ signature_details["signature"], headers_string
+ ):
print("Bad signature")
- return HttpResponseBadRequest()
+ # return HttpResponseBadRequest("Bad signature")
return JsonResponse({"status": "OK"})
@@ -190,24 +205,24 @@ class Webfinger(View):
resource = request.GET.get("resource")
if not resource.startswith("acct:"):
raise Http404("Not an account resource")
- handle = resource[5:]
+ handle = resource[5:].replace("testfedi", "feditest")
identity = by_handle_or_404(request, handle)
return JsonResponse(
{
"subject": f"acct:{identity.handle}",
"aliases": [
- f"https://{settings.DEFAULT_DOMAIN}/@{identity.short_handle}",
+ identity.urls.view_short.full(),
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
- "href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.view}",
+ "href": identity.urls.view_short.full(),
},
{
"rel": "self",
"type": "application/activity+json",
- "href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
+ "href": identity.urls.actor.full(),
},
],
}