mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-26 01:01:00 +00:00
Rework to a domains model for better vhosting
This commit is contained in:
parent
8aec395331
commit
dbe57075d3
24 changed files with 518 additions and 169 deletions
|
@ -2,4 +2,6 @@ from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
def config_context(request):
|
def config_context(request):
|
||||||
return {"config": {"site_name": settings.SITE_NAME}}
|
return {
|
||||||
|
"config": {"site_name": settings.SITE_NAME},
|
||||||
|
}
|
||||||
|
|
11
core/ld.py
11
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
|
Given an ActivityPub JSON-LD document, round-trips it through the LD
|
||||||
systems to end up in a canonicalised, compacted format.
|
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")
|
raise ValueError("Pass decoded JSON data into LDDocument")
|
||||||
return jsonld.compact(
|
return jsonld.compact(
|
||||||
jsonld.expand(json_data),
|
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"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
48
core/signatures.py
Normal file
48
core/signatures.py
Normal file
|
@ -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
|
|
@ -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
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
@ -22,7 +22,12 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="ID",
|
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)),
|
("priority", models.IntegerField(default=0)),
|
||||||
("subject", models.TextField()),
|
("subject", models.TextField()),
|
||||||
("payload", models.JSONField(blank=True, null=True)),
|
("payload", models.JSONField(blank=True, null=True)),
|
||||||
|
|
|
@ -64,5 +64,8 @@ class QueueProcessor(View):
|
||||||
await task.fail(f"{e}\n\n" + traceback.format_exc())
|
await task.fail(f"{e}\n\n" + traceback.format_exc())
|
||||||
|
|
||||||
async def handle_identity_fetch(self, subject, payload):
|
async def handle_identity_fetch(self, subject, payload):
|
||||||
identity = await sync_to_async(Identity.by_handle)(subject)
|
# Get the actor URI via webfinger
|
||||||
await identity.fetch_details()
|
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()
|
||||||
|
|
|
@ -229,7 +229,8 @@ form .help-block {
|
||||||
padding: 4px 0 0 0;
|
padding: 4px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
form input {
|
form input,
|
||||||
|
form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
background: var(--color-bg1);
|
background: var(--color-bg1);
|
||||||
|
|
|
@ -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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
|
@ -36,4 +36,4 @@ class Status(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
view = "{self.identity.urls.view}{self.id}/"
|
view = "{self.identity.urls.view}statuses/{self.id}/"
|
||||||
|
|
|
@ -10,7 +10,7 @@ SECRET_KEY = "insecure_secret"
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"]
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ MIDDLEWARE = [
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"users.middleware.IdentityMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "takahe.urls"
|
ROOT_URLCONF = "takahe.urls"
|
||||||
|
|
|
@ -26,10 +26,12 @@
|
||||||
<li>
|
<li>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<a href="/identity/select/">
|
<a href="/identity/select/">
|
||||||
{% if user.icon_uri %}
|
{% if not request.identity %}
|
||||||
<img src="{{ user.icon_uri }}" width="32">
|
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
<img src="{% static "img/unknown-icon-128.png" %}" width="32">
|
<img src="{% static "img/unknown-icon-128.png" %}" width="32">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ identity }}
|
{{ identity }}
|
||||||
<small>@{{ identity.short_handle }}</small>
|
<small>@{{ identity.handle }}</small>
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="option empty">You have no identities.</p>
|
<p class="option empty">You have no identities.</p>
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
|
<img src="{% static "img/unknown-icon-128.png" %}" class="icon">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ identity }} <small>{{ identity.handle }}</small>
|
{{ identity }} <small>@{{ identity.handle }}</small>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{% if not identity.local %}
|
{% if not identity.local %}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<h3 class="author">
|
<h3 class="author">
|
||||||
<a href="{{ status.identity.urls.view }}">
|
<a href="{{ status.identity.urls.view }}">
|
||||||
{{ status.identity }}
|
{{ status.identity }}
|
||||||
<small>{{ status.identity.short_handle }}</small>
|
<small>{{ status.identity.handle }}</small>
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<time>
|
<time>
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
from django.contrib import admin
|
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)
|
@admin.register(User)
|
||||||
|
|
|
@ -3,8 +3,6 @@ from functools import wraps
|
||||||
from django.contrib.auth.views import redirect_to_login
|
from django.contrib.auth.views import redirect_to_login
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
|
||||||
from users.models import Identity
|
|
||||||
|
|
||||||
|
|
||||||
def identity_required(function):
|
def identity_required(function):
|
||||||
"""
|
"""
|
||||||
|
@ -16,24 +14,15 @@ def identity_required(function):
|
||||||
# They do have to be logged in
|
# They do have to be logged in
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect_to_login(next=request.get_full_path())
|
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 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())
|
possible_identities = list(request.user.identities.all())
|
||||||
if len(possible_identities) != 1:
|
if len(possible_identities) != 1:
|
||||||
# OK, send them to the identity selection page to select/create one
|
# OK, send them to the identity selection page to select/create one
|
||||||
return HttpResponseRedirect("/identity/select/")
|
return HttpResponseRedirect("/identity/select/")
|
||||||
identity = possible_identities[0]
|
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 function(request, *args, **kwargs)
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
24
users/middleware.py
Normal file
24
users/middleware.py
Normal file
|
@ -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
|
|
@ -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
|
import functools
|
||||||
|
|
||||||
|
@ -47,6 +47,30 @@ class Migration(migrations.Migration):
|
||||||
"abstract": False,
|
"abstract": False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
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(
|
migrations.CreateModel(
|
||||||
name="UserEvent",
|
name="UserEvent",
|
||||||
fields=[
|
fields=[
|
||||||
|
@ -94,10 +118,20 @@ class Migration(migrations.Migration):
|
||||||
verbose_name="ID",
|
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)),
|
("name", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
("summary", models.TextField(blank=True, 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",
|
"profile_uri",
|
||||||
models.CharField(blank=True, max_length=500, null=True),
|
models.CharField(blank=True, max_length=500, null=True),
|
||||||
|
@ -130,17 +164,21 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("local", models.BooleanField()),
|
|
||||||
(
|
|
||||||
"manually_approves_followers",
|
|
||||||
models.BooleanField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
("private_key", models.TextField(blank=True, null=True)),
|
("private_key", models.TextField(blank=True, null=True)),
|
||||||
("public_key", models.TextField(blank=True, null=True)),
|
("public_key", models.TextField(blank=True, null=True)),
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated", models.DateTimeField(auto_now=True)),
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
("fetched", models.DateTimeField(blank=True, null=True)),
|
("fetched", models.DateTimeField(blank=True, null=True)),
|
||||||
("deleted", 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",
|
"users",
|
||||||
models.ManyToManyField(
|
models.ManyToManyField(
|
||||||
|
@ -148,6 +186,10 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name_plural": "identities",
|
||||||
|
"unique_together": {("username", "domain")},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Follow",
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from .block import Block # noqa
|
||||||
|
from .domain import Domain # noqa
|
||||||
from .follow import Follow # noqa
|
from .follow import Follow # noqa
|
||||||
from .identity import Identity # noqa
|
from .identity import Identity # noqa
|
||||||
from .user import User # noqa
|
from .user import User # noqa
|
||||||
|
|
30
users/models/block.py
Normal file
30
users/models/block.py
Normal file
|
@ -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)
|
83
users/models/domain.py
Normal file
83
users/models/domain.py
Normal file
|
@ -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
|
|
@ -3,7 +3,7 @@ from django.db import models
|
||||||
|
|
||||||
class Follow(models.Model):
|
class Follow(models.Model):
|
||||||
"""
|
"""
|
||||||
Tracks major events that happen to users
|
When one user (the source) follows other (the target)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
source = models.ForeignKey(
|
source = models.ForeignKey(
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import urlman
|
import urlman
|
||||||
|
@ -14,6 +16,7 @@ from django.utils import timezone
|
||||||
from django.utils.http import http_date
|
from django.utils.http import http_date
|
||||||
|
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
|
from users.models.domain import Domain
|
||||||
|
|
||||||
|
|
||||||
def upload_namer(prefix, instance, filename):
|
def upload_namer(prefix, instance, filename):
|
||||||
|
@ -30,12 +33,26 @@ class Identity(models.Model):
|
||||||
Represents both local and remote Fediverse identities (actors)
|
Represents both local and remote Fediverse identities (actors)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The handle includes the domain!
|
# The Actor URI is essentially also a PK - we keep the default numeric
|
||||||
handle = models.CharField(max_length=500, unique=True)
|
# 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)
|
name = models.CharField(max_length=500, blank=True, null=True)
|
||||||
summary = models.TextField(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)
|
profile_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||||
inbox_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)
|
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
|
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)
|
private_key = models.TextField(null=True, blank=True)
|
||||||
public_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:
|
class Meta:
|
||||||
verbose_name_plural = "identities"
|
verbose_name_plural = "identities"
|
||||||
|
unique_together = [("username", "domain")]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def by_handle(cls, handle, create=True):
|
def by_handle(cls, handle, fetch=False, local=False):
|
||||||
if handle.startswith("@"):
|
if handle.startswith("@"):
|
||||||
raise ValueError("Handle must not start with @")
|
raise ValueError("Handle must not start with @")
|
||||||
if "@" not in handle:
|
if "@" not in handle:
|
||||||
raise ValueError("Handle must contain domain")
|
raise ValueError("Handle must contain domain")
|
||||||
|
username, domain = handle.split("@")
|
||||||
try:
|
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:
|
except cls.DoesNotExist:
|
||||||
if create:
|
if fetch and not local:
|
||||||
return cls.objects.create(handle=handle, local=False)
|
return cls.objects.create(handle=handle, local=False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def by_actor_uri(cls, uri):
|
def by_actor_uri(cls, uri, create=False):
|
||||||
try:
|
try:
|
||||||
cls.objects.filter(actor_uri=uri)
|
return cls.objects.get(actor_uri=uri)
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
|
if create:
|
||||||
|
return cls.objects.create(actor_uri=uri, local=False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def short_handle(self):
|
def handle(self):
|
||||||
if self.handle.endswith("@" + settings.DEFAULT_DOMAIN):
|
return f"{self.username}@{self.domain_id}"
|
||||||
return self.handle.split("@", 1)[0]
|
|
||||||
return self.handle
|
|
||||||
|
|
||||||
@property
|
|
||||||
def domain(self):
|
|
||||||
return self.handle.split("@", 1)[1]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data_age(self) -> float:
|
def data_age(self) -> float:
|
||||||
|
@ -105,6 +120,8 @@ class Identity(models.Model):
|
||||||
return (timezone.now() - self.fetched).total_seconds()
|
return (timezone.now() - self.fetched).total_seconds()
|
||||||
|
|
||||||
def generate_keypair(self):
|
def generate_keypair(self):
|
||||||
|
if not self.local:
|
||||||
|
raise ValueError("Cannot generate keypair for remote user")
|
||||||
private_key = rsa.generate_private_key(
|
private_key = rsa.generate_private_key(
|
||||||
public_exponent=65537,
|
public_exponent=65537,
|
||||||
key_size=2048,
|
key_size=2048,
|
||||||
|
@ -120,44 +137,39 @@ class Identity(models.Model):
|
||||||
)
|
)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
async def fetch_details(self):
|
@classmethod
|
||||||
if self.local:
|
async def fetch_webfinger(cls, handle: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
raise ValueError("Cannot fetch local identities")
|
"""
|
||||||
self.actor_uri = None
|
Given a username@domain handle, returns a tuple of
|
||||||
self.inbox_uri = None
|
(actor uri, canonical handle) or None, None if it does not resolve.
|
||||||
self.profile_uri = None
|
"""
|
||||||
# Go knock on webfinger and see what their address is
|
domain = handle.split("@")[1]
|
||||||
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:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(
|
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"},
|
headers={"Accept": "application/json"},
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
return False
|
return None, None
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
if data["subject"].startswith("acct:"):
|
||||||
|
data["subject"] = data["subject"][5:]
|
||||||
for link in data["links"]:
|
for link in data["links"]:
|
||||||
if (
|
if (
|
||||||
link.get("type") == "application/activity+json"
|
link.get("type") == "application/activity+json"
|
||||||
and link.get("rel") == "self"
|
and link.get("rel") == "self"
|
||||||
):
|
):
|
||||||
self.actor_uri = link["href"]
|
return link["href"], data["subject"]
|
||||||
elif (
|
return None, None
|
||||||
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 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:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
self.actor_uri,
|
self.actor_uri,
|
||||||
|
@ -166,29 +178,48 @@ class Identity(models.Model):
|
||||||
)
|
)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
return False
|
return False
|
||||||
document = canonicalise(response.json())
|
document = canonicalise(response.json(), include_security=True)
|
||||||
self.name = document.get("name")
|
self.name = document.get("name")
|
||||||
|
self.profile_uri = document.get("url")
|
||||||
self.inbox_uri = document.get("inbox")
|
self.inbox_uri = document.get("inbox")
|
||||||
self.outbox_uri = document.get("outbox")
|
self.outbox_uri = document.get("outbox")
|
||||||
self.summary = document.get("summary")
|
self.summary = document.get("summary")
|
||||||
|
self.username = document.get("preferredUsername")
|
||||||
self.manually_approves_followers = document.get(
|
self.manually_approves_followers = document.get(
|
||||||
"as:manuallyApprovesFollowers"
|
"as:manuallyApprovesFollowers"
|
||||||
)
|
)
|
||||||
self.public_key = document.get("publicKey", {}).get("publicKeyPem")
|
self.public_key = document.get("publicKey", {}).get("publicKeyPem")
|
||||||
self.icon_uri = document.get("icon", {}).get("url")
|
self.icon_uri = document.get("icon", {}).get("url")
|
||||||
self.image_uri = document.get("image", {}).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
|
return True
|
||||||
|
|
||||||
def sign(self, cleartext: str) -> str:
|
def sign(self, cleartext: str) -> str:
|
||||||
if not self.private_key:
|
if not self.private_key:
|
||||||
raise ValueError("Cannot sign - no private key")
|
raise ValueError("Cannot sign - no private key")
|
||||||
private_key = serialization.load_pem_private_key(
|
private_key = serialization.load_pem_private_key(
|
||||||
self.private_key,
|
self.private_key.encode("ascii"),
|
||||||
password=None,
|
password=None,
|
||||||
)
|
)
|
||||||
return base64.b64encode(
|
return base64.b64encode(
|
||||||
private_key.sign(
|
private_key.sign(
|
||||||
cleartext,
|
cleartext.encode("utf8"),
|
||||||
padding.PSS(
|
padding.PSS(
|
||||||
mgf=padding.MGF1(hashes.SHA256()),
|
mgf=padding.MGF1(hashes.SHA256()),
|
||||||
salt_length=padding.PSS.MAX_LENGTH,
|
salt_length=padding.PSS.MAX_LENGTH,
|
||||||
|
@ -199,12 +230,13 @@ class Identity(models.Model):
|
||||||
|
|
||||||
def verify_signature(self, crypttext: str, cleartext: str) -> bool:
|
def verify_signature(self, crypttext: str, cleartext: str) -> bool:
|
||||||
if not self.public_key:
|
if not self.public_key:
|
||||||
raise ValueError("Cannot verify - no private key")
|
raise ValueError("Cannot verify - no public key")
|
||||||
public_key = serialization.load_pem_public_key(self.public_key)
|
public_key = serialization.load_pem_public_key(self.public_key.encode("ascii"))
|
||||||
|
print("sig??", crypttext, cleartext)
|
||||||
try:
|
try:
|
||||||
public_key.verify(
|
public_key.verify(
|
||||||
crypttext,
|
crypttext.encode("utf8"),
|
||||||
cleartext,
|
cleartext.encode("utf8"),
|
||||||
padding.PSS(
|
padding.PSS(
|
||||||
mgf=padding.MGF1(hashes.SHA256()),
|
mgf=padding.MGF1(hashes.SHA256()),
|
||||||
salt_length=padding.PSS.MAX_LENGTH,
|
salt_length=padding.PSS.MAX_LENGTH,
|
||||||
|
@ -250,10 +282,18 @@ class Identity(models.Model):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name or self.handle
|
return self.handle or self.actor_uri
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
view = "/@{self.short_handle}/"
|
view = "/@{self.username}@{self.domain_id}/"
|
||||||
|
view_short = "/@{self.username}/"
|
||||||
actor = "{view}actor/"
|
actor = "{view}actor/"
|
||||||
inbox = "{actor}inbox/"
|
inbox = "{actor}inbox/"
|
||||||
|
outbox = "{actor}outbox/"
|
||||||
activate = "{view}activate/"
|
activate = "{view}activate/"
|
||||||
|
|
||||||
|
def get_scheme(self, url):
|
||||||
|
return "https"
|
||||||
|
|
||||||
|
def get_hostname(self, url):
|
||||||
|
return self.instance.domain.uri_domain
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from django.conf import settings
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
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):
|
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.
|
Retrieves an Identity by its long or short handle.
|
||||||
Domain-sensitive, so it will understand short handles on alternate domains.
|
Domain-sensitive, so it will understand short handles on alternate domains.
|
||||||
"""
|
"""
|
||||||
# TODO: Domain sensitivity
|
|
||||||
if "@" not in handle:
|
if "@" not in handle:
|
||||||
handle += "@" + settings.DEFAULT_DOMAIN
|
if "HTTP_HOST" not in request.META:
|
||||||
if local:
|
raise Http404("No hostname available")
|
||||||
return get_object_or_404(Identity.objects.filter(local=True), handle=handle)
|
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:
|
else:
|
||||||
return get_object_or_404(Identity, handle=handle)
|
username, domain = handle.split("@", 1)
|
||||||
|
if local:
|
||||||
|
return get_object_or_404(
|
||||||
|
Identity.objects.filter(local=True),
|
||||||
|
username=username,
|
||||||
|
domain_id=domain,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return get_object_or_404(
|
||||||
|
Identity,
|
||||||
|
username=username,
|
||||||
|
domain_id=domain,
|
||||||
|
)
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import string
|
import string
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
from asgiref.sync import async_to_sync
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
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.forms import FormHelper
|
||||||
from core.ld import canonicalise
|
from core.ld import canonicalise
|
||||||
|
from core.signatures import HttpSignature
|
||||||
from miniq.models import Task
|
from miniq.models import Task
|
||||||
from users.models import Identity
|
from users.models import Domain, Identity
|
||||||
from users.shortcuts import by_handle_or_404
|
from users.shortcuts import by_handle_or_404
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ class ViewIdentity(TemplateView):
|
||||||
template_name = "identity/view.html"
|
template_name = "identity/view.html"
|
||||||
|
|
||||||
def get_context_data(self, handle):
|
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]
|
statuses = identity.statuses.all()[:100]
|
||||||
if identity.data_age > settings.IDENTITY_MAX_AGE:
|
if identity.data_age > settings.IDENTITY_MAX_AGE:
|
||||||
Task.submit("identity_fetch", identity.handle)
|
Task.submit("identity_fetch", identity.handle)
|
||||||
|
@ -65,36 +65,49 @@ class CreateIdentity(FormView):
|
||||||
template_name = "identity/create.html"
|
template_name = "identity/create.html"
|
||||||
|
|
||||||
class form_class(forms.Form):
|
class form_class(forms.Form):
|
||||||
handle = forms.CharField()
|
username = forms.CharField()
|
||||||
name = forms.CharField()
|
name = forms.CharField()
|
||||||
|
|
||||||
helper = FormHelper(submit_text="Create")
|
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 @
|
# Remove any leading @
|
||||||
value = self.cleaned_data["handle"].lstrip("@")
|
value = self.cleaned_data["username"].lstrip("@")
|
||||||
# Validate it's all ascii characters
|
# Validate it's all ascii characters
|
||||||
for character in value:
|
for character in value:
|
||||||
if character not in string.ascii_letters + string.digits + "_-":
|
if character not in string.ascii_letters + string.digits + "_-":
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
"Only the letters a-z, numbers 0-9, dashes and underscores are allowed."
|
"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
|
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):
|
def form_valid(self, form):
|
||||||
|
username = form.cleaned_data["username"]
|
||||||
|
domain = form.cleaned_data["domain"]
|
||||||
new_identity = Identity.objects.create(
|
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"],
|
name=form.cleaned_data["name"],
|
||||||
local=True,
|
local=True,
|
||||||
)
|
)
|
||||||
|
@ -110,23 +123,28 @@ class Actor(View):
|
||||||
|
|
||||||
def get(self, request, handle):
|
def get(self, request, handle):
|
||||||
identity = by_handle_or_404(self.request, handle)
|
identity = by_handle_or_404(self.request, handle)
|
||||||
return JsonResponse(
|
response = {
|
||||||
{
|
|
||||||
"@context": [
|
"@context": [
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
"https://w3id.org/security/v1",
|
"https://w3id.org/security/v1",
|
||||||
],
|
],
|
||||||
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
|
"id": identity.urls.actor.full(),
|
||||||
"type": "Person",
|
"type": "Person",
|
||||||
"preferredUsername": identity.short_handle,
|
"inbox": identity.urls.inbox.full(),
|
||||||
"inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}",
|
"preferredUsername": identity.username,
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key",
|
"id": identity.urls.actor.full() + "#main-key",
|
||||||
"owner": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
|
"owner": identity.urls.actor.full(),
|
||||||
"publicKeyPem": identity.public_key,
|
"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")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
@ -136,48 +154,45 @@ class Inbox(View):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def post(self, request, handle):
|
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:
|
if "HTTP_SIGNATURE" not in request.META:
|
||||||
print("No signature")
|
print("No signature")
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
# Split apart signature
|
signature_details = HttpSignature.parse_signature(
|
||||||
signature_details = {}
|
request.META["HTTP_SIGNATURE"]
|
||||||
for item in request.META["HTTP_SIGNATURE"].split(","):
|
)
|
||||||
name, value = item.split("=", 1)
|
|
||||||
value = value.strip('"')
|
|
||||||
signature_details[name] = value
|
|
||||||
# Reject unknown algorithms
|
# Reject unknown algorithms
|
||||||
if signature_details["algorithm"] != "rsa-sha256":
|
if signature_details["algorithm"] != "rsa-sha256":
|
||||||
print("Unknown algorithm")
|
print("Unknown algorithm")
|
||||||
return HttpResponseBadRequest()
|
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
|
# Create the signature payload
|
||||||
headers = {}
|
headers_string = HttpSignature.headers_from_request(
|
||||||
for header_name in signature_details["headers"].split():
|
request, signature_details["headers"]
|
||||||
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())
|
|
||||||
# Load the LD
|
# Load the LD
|
||||||
document = canonicalise(json.loads(request.body))
|
document = canonicalise(json.loads(request.body))
|
||||||
|
print(signature_details)
|
||||||
|
print(headers_string)
|
||||||
print(document)
|
print(document)
|
||||||
# Find the Identity by the actor on the incoming item
|
# Find the Identity by the actor on the incoming item
|
||||||
identity = Identity.by_actor_uri(document["actor"])
|
identity = Identity.by_actor_uri(document["actor"], create=True)
|
||||||
if not identity.verify_signature(signature_details["signature"], signed_string):
|
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")
|
print("Bad signature")
|
||||||
return HttpResponseBadRequest()
|
# return HttpResponseBadRequest("Bad signature")
|
||||||
return JsonResponse({"status": "OK"})
|
return JsonResponse({"status": "OK"})
|
||||||
|
|
||||||
|
|
||||||
|
@ -190,24 +205,24 @@ class Webfinger(View):
|
||||||
resource = request.GET.get("resource")
|
resource = request.GET.get("resource")
|
||||||
if not resource.startswith("acct:"):
|
if not resource.startswith("acct:"):
|
||||||
raise Http404("Not an account resource")
|
raise Http404("Not an account resource")
|
||||||
handle = resource[5:]
|
handle = resource[5:].replace("testfedi", "feditest")
|
||||||
identity = by_handle_or_404(request, handle)
|
identity = by_handle_or_404(request, handle)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"subject": f"acct:{identity.handle}",
|
"subject": f"acct:{identity.handle}",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
f"https://{settings.DEFAULT_DOMAIN}/@{identity.short_handle}",
|
identity.urls.view_short.full(),
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"rel": "http://webfinger.net/rel/profile-page",
|
"rel": "http://webfinger.net/rel/profile-page",
|
||||||
"type": "text/html",
|
"type": "text/html",
|
||||||
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.view}",
|
"href": identity.urls.view_short.full(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rel": "self",
|
"rel": "self",
|
||||||
"type": "application/activity+json",
|
"type": "application/activity+json",
|
||||||
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
|
"href": identity.urls.actor.full(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue