summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-12 21:14:21 -0700
committerAndrew Godwin2022-11-12 21:14:21 -0700
commit878f56b411279cd9865a7ec05f1d14c9f70f6187 (patch)
tree93f3c65e109a014041e4380a854bdf8b4dd7fe6d
parentdd4328ae523bb375dd871e85d1bacd9311e87a89 (diff)
downloadtakahe-878f56b411279cd9865a7ec05f1d14c9f70f6187.tar.gz
takahe-878f56b411279cd9865a7ec05f1d14c9f70f6187.tar.bz2
takahe-878f56b411279cd9865a7ec05f1d14c9f70f6187.zip
Post URIs and host-meta
-rw-r--r--activities/migrations/0003_alter_post_object_uri.py18
-rw-r--r--activities/models/fan_out.py2
-rw-r--r--activities/models/post.py54
-rw-r--r--core/ld.py12
-rw-r--r--core/signatures.py4
-rw-r--r--takahe/urls.py9
-rw-r--r--templates/activities/_post.html8
-rw-r--r--users/admin.py2
-rw-r--r--users/models/follow.py6
-rw-r--r--users/models/inbox_message.py4
-rw-r--r--users/views/activitypub.py148
-rw-r--r--users/views/identity.py129
12 files changed, 234 insertions, 162 deletions
diff --git a/activities/migrations/0003_alter_post_object_uri.py b/activities/migrations/0003_alter_post_object_uri.py
new file mode 100644
index 0000000..4f98bc9
--- /dev/null
+++ b/activities/migrations/0003_alter_post_object_uri.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.3 on 2022-11-13 03:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("activities", "0002_fan_out"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="post",
+ name="object_uri",
+ field=models.CharField(blank=True, max_length=500, null=True, unique=True),
+ ),
+ ]
diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py
index 96e8df7..958fbe2 100644
--- a/activities/models/fan_out.py
+++ b/activities/models/fan_out.py
@@ -32,7 +32,7 @@ class FanOutStates(StateGraph):
await HttpSignature.signed_request(
uri=fan_out.identity.inbox_uri,
body=canonicalise(post.to_create_ap()),
- private_key=post.author.public_key,
+ private_key=post.author.private_key,
key_id=post.author.public_key_id,
)
return cls.sent
diff --git a/activities/models/post.py b/activities/models/post.py
index 4c40033..ec5e629 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -7,7 +7,7 @@ from django.utils import timezone
from activities.models.fan_out import FanOut
from activities.models.timeline_event import TimelineEvent
from core.html import sanitize_post
-from core.ld import format_date
+from core.ld import format_ld_date, parse_ld_date
from stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import Follow
from users.models.identity import Identity
@@ -53,7 +53,7 @@ class Post(StatorModel):
local = models.BooleanField()
# The canonical object ID
- object_uri = models.CharField(max_length=500, blank=True, null=True)
+ object_uri = models.CharField(max_length=500, blank=True, null=True, unique=True)
# Who should be able to see this Post
visibility = models.IntegerField(
@@ -145,18 +145,22 @@ class Post(StatorModel):
"""
# Send a copy to all people who follow this user
post = await self.afetch_full()
- async for follow in post.author.inbound_follows.all():
+ async for follow in post.author.inbound_follows.select_related(
+ "source", "target"
+ ):
+ if follow.source.local or follow.target.local:
+ await FanOut.objects.acreate(
+ identity_id=follow.source_id,
+ type=FanOut.Types.post,
+ subject_post=post,
+ )
+ # And one for themselves if they're local
+ if post.author.local:
await FanOut.objects.acreate(
- identity_id=follow.source_id,
+ identity_id=post.author_id,
type=FanOut.Types.post,
subject_post=post,
)
- # And one for themselves
- await FanOut.objects.acreate(
- identity_id=post.author_id,
- type=FanOut.Types.post,
- subject_post=post,
- )
def to_ap(self) -> Dict:
"""
@@ -165,7 +169,7 @@ class Post(StatorModel):
value = {
"type": "Note",
"id": self.object_uri,
- "published": format_date(self.created),
+ "published": format_ld_date(self.created),
"attributedTo": self.author.actor_uri,
"content": self.safe_content,
"to": "as:Public",
@@ -190,7 +194,7 @@ class Post(StatorModel):
### ActivityPub (inbound) ###
@classmethod
- def by_ap(cls, data, create=False) -> "Post":
+ def by_ap(cls, data, create=False, update=False) -> "Post":
"""
Retrieves a Post instance by its ActivityPub JSON object.
@@ -198,25 +202,33 @@ class Post(StatorModel):
Raises KeyError if it's not found and create is False.
"""
# Do we have one with the right ID?
+ created = False
try:
- return cls.objects.get(object_uri=data["id"])
+ post = cls.objects.get(object_uri=data["id"])
except cls.DoesNotExist:
if create:
# Resolve the author
author = Identity.by_actor_uri(data["attributedTo"], create=create)
- return cls.objects.create(
+ post = cls.objects.create(
+ object_uri=data["id"],
author=author,
content=sanitize_post(data["content"]),
- summary=data.get("summary", None),
- sensitive=data.get("as:sensitive", False),
- url=data.get("url", None),
local=False,
- # TODO: to
- # TODO: mentions
- # TODO: visibility
)
+ created = True
else:
raise KeyError(f"No post with ID {data['id']}", data)
+ if update or created:
+ post.content = sanitize_post(data["content"])
+ post.summary = data.get("summary", None)
+ post.sensitive = data.get("as:sensitive", False)
+ post.url = data.get("url", None)
+ post.authored = parse_ld_date(data.get("published", None))
+ # TODO: to
+ # TODO: mentions
+ # TODO: visibility
+ post.save()
+ return post
@classmethod
def handle_create_ap(cls, data):
@@ -227,7 +239,7 @@ class Post(StatorModel):
if data["actor"] != data["object"]["attributedTo"]:
raise ValueError("Create actor does not match its Post object", data)
# Create it
- post = cls.by_ap(data["object"], create=True)
+ post = cls.by_ap(data["object"], create=True, update=True)
# Make timeline events as appropriate
for follow in Follow.objects.filter(target=post.author, source__local=True):
TimelineEvent.add_post(follow.source, post)
diff --git a/core/ld.py b/core/ld.py
index 7863480..346708c 100644
--- a/core/ld.py
+++ b/core/ld.py
@@ -1,6 +1,6 @@
import datetime
import urllib.parse as urllib_parse
-from typing import Dict, List, Union
+from typing import Dict, List, Optional, Union
from pyld import jsonld
from pyld.jsonld import JsonLdError
@@ -414,5 +414,13 @@ def canonicalise(json_data: Dict, include_security: bool = False) -> Dict:
return jsonld.compact(jsonld.expand(json_data), context)
-def format_date(value: datetime.datetime) -> str:
+def format_ld_date(value: datetime.datetime) -> str:
return value.strftime(DATETIME_FORMAT)
+
+
+def parse_ld_date(value: Optional[str]) -> Optional[datetime.datetime]:
+ if value is None:
+ return None
+ return datetime.datetime.strptime(value, DATETIME_FORMAT).replace(
+ tzinfo=datetime.timezone.utc
+ )
diff --git a/core/signatures.py b/core/signatures.py
index 74b5324..0959333 100644
--- a/core/signatures.py
+++ b/core/signatures.py
@@ -11,7 +11,7 @@ from django.utils.http import http_date, parse_http_date
from OpenSSL import crypto
from pyld import jsonld
-from core.ld import format_date
+from core.ld import format_ld_date
class VerificationError(BaseException):
@@ -261,7 +261,7 @@ class LDSignature:
options: Dict[str, str] = {
"@context": "https://w3id.org/identity/v1",
"creator": key_id,
- "created": format_date(timezone.now()),
+ "created": format_ld_date(timezone.now()),
}
# Get the normalised hash of each document
final_hash = cls.normalized_hash(options) + cls.normalized_hash(document)
diff --git a/takahe/urls.py b/takahe/urls.py
index 764c8e9..672f7ce 100644
--- a/takahe/urls.py
+++ b/takahe/urls.py
@@ -3,7 +3,7 @@ from django.urls import path
from core import views as core
from stator import views as stator
-from users.views import auth, identity
+from users.views import activitypub, auth, identity
urlpatterns = [
path("", core.homepage),
@@ -12,15 +12,16 @@ urlpatterns = [
path("auth/logout/", auth.Logout.as_view()),
# Identity views
path("@<handle>/", identity.ViewIdentity.as_view()),
- path("@<handle>/actor/", identity.Actor.as_view()),
- path("@<handle>/actor/inbox/", identity.Inbox.as_view()),
+ path("@<handle>/actor/", activitypub.Actor.as_view()),
+ path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Identity selection
path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()),
path("identity/create/", identity.CreateIdentity.as_view()),
# Well-known endpoints
- path(".well-known/webfinger", identity.Webfinger.as_view()),
+ path(".well-known/webfinger", activitypub.Webfinger.as_view()),
+ path(".well-known/host-meta", activitypub.HostMeta.as_view()),
# Task runner
path(".stator/runner/", stator.RequestRunner.as_view()),
# Django admin
diff --git a/templates/activities/_post.html b/templates/activities/_post.html
index 2ac57f3..eef09db 100644
--- a/templates/activities/_post.html
+++ b/templates/activities/_post.html
@@ -13,7 +13,13 @@
</a>
</h3>
<time>
- <a href="{{ post.urls.view }}">{{ post.created | timesince }} ago</a>
+ <a href="{{ post.urls.view }}">
+ {% if post.authored %}
+ {{ post.authored | timesince }} ago
+ {% else %}
+ {{ post.created | timesince }} ago
+ {% endif %}
+ </a>
</time>
<div class="content">
{{ post.safe_content }}
diff --git a/users/admin.py b/users/admin.py
index 61a2bc8..e52e41c 100644
--- a/users/admin.py
+++ b/users/admin.py
@@ -38,7 +38,7 @@ class FollowAdmin(admin.ModelAdmin):
@admin.register(InboxMessage)
class InboxMessageAdmin(admin.ModelAdmin):
- list_display = ["id", "state", "state_attempted", "message_type"]
+ list_display = ["id", "state", "state_attempted", "message_type", "message_actor"]
actions = ["reset_state"]
@admin.action(description="Reset State")
diff --git a/users/models/follow.py b/users/models/follow.py
index 238081e..0236d19 100644
--- a/users/models/follow.py
+++ b/users/models/follow.py
@@ -37,7 +37,7 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request(
uri=follow.target.inbox_uri,
body=canonicalise(follow.to_ap()),
- private_key=follow.source.public_key,
+ private_key=follow.source.private_key,
key_id=follow.source.public_key_id,
)
return cls.local_requested
@@ -57,7 +57,7 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request(
uri=follow.source.inbox_uri,
body=canonicalise(follow.to_accept_ap()),
- private_key=follow.target.public_key,
+ private_key=follow.target.private_key,
key_id=follow.target.public_key_id,
)
return cls.accepted
@@ -71,7 +71,7 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request(
uri=follow.target.inbox_uri,
body=canonicalise(follow.to_undo_ap()),
- private_key=follow.source.public_key,
+ private_key=follow.source.private_key,
key_id=follow.source.public_key_id,
)
return cls.undone_remotely
diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py
index ea55b17..43424c9 100644
--- a/users/models/inbox_message.py
+++ b/users/models/inbox_message.py
@@ -66,3 +66,7 @@ class InboxMessage(StatorModel):
@property
def message_object_type(self):
return self.message["object"]["type"].lower()
+
+ @property
+ def message_actor(self):
+ return self.message.get("actor")
diff --git a/users/views/activitypub.py b/users/views/activitypub.py
new file mode 100644
index 0000000..54f04bc
--- /dev/null
+++ b/users/views/activitypub.py
@@ -0,0 +1,148 @@
+import json
+
+from asgiref.sync import async_to_sync
+from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_exempt
+from django.views.generic import View
+
+from core.ld import canonicalise
+from core.signatures import (
+ HttpSignature,
+ LDSignature,
+ VerificationError,
+ VerificationFormatError,
+)
+from users.models import Identity, InboxMessage
+from users.shortcuts import by_handle_or_404
+
+
+class HttpResponseUnauthorized(HttpResponse):
+ status_code = 401
+
+
+class HostMeta(View):
+ """
+ Returns a canned host-meta response
+ """
+
+ def get(self, request):
+ return HttpResponse(
+ """<?xml version="1.0" encoding="UTF-8"?>
+ <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
+ <Link rel="lrdd" template="https://%s/.well-known/webfinger?resource={uri}"/>
+ </XRD>"""
+ % request.META["HTTP_HOST"],
+ content_type="application/xml",
+ )
+
+
+class Webfinger(View):
+ """
+ Services webfinger requests
+ """
+
+ def get(self, request):
+ resource = request.GET.get("resource")
+ if not resource.startswith("acct:"):
+ raise Http404("Not an account resource")
+ handle = resource[5:].replace("testfedi", "feditest")
+ identity = by_handle_or_404(request, handle)
+ return JsonResponse(
+ {
+ "subject": f"acct:{identity.handle}",
+ "aliases": [
+ identity.urls.view_short.full(),
+ ],
+ "links": [
+ {
+ "rel": "http://webfinger.net/rel/profile-page",
+ "type": "text/html",
+ "href": identity.urls.view_short.full(),
+ },
+ {
+ "rel": "self",
+ "type": "application/activity+json",
+ "href": identity.actor_uri,
+ },
+ ],
+ }
+ )
+
+
+class Actor(View):
+ """
+ Returns the AP Actor object
+ """
+
+ def get(self, request, handle):
+ identity = by_handle_or_404(self.request, handle)
+ response = {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ ],
+ "id": identity.actor_uri,
+ "type": "Person",
+ "inbox": identity.actor_uri + "inbox/",
+ "preferredUsername": identity.username,
+ "publicKey": {
+ "id": identity.public_key_id,
+ "owner": identity.actor_uri,
+ "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")
+class Inbox(View):
+ """
+ AP Inbox endpoint
+ """
+
+ def post(self, request, handle):
+ # Load the LD
+ document = canonicalise(json.loads(request.body), include_security=True)
+ # Find the Identity by the actor on the incoming item
+ # This ensures that the signature used for the headers matches the actor
+ # described in the payload.
+ 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 get actor")
+ return HttpResponseBadRequest("Cannot retrieve actor")
+ # If there's a "signature" payload, verify against that
+ if "signature" in document:
+ try:
+ LDSignature.verify_signature(document, identity.public_key)
+ except VerificationFormatError as e:
+ print("Bad LD signature format:", e.args[0])
+ return HttpResponseBadRequest(e.args[0])
+ except VerificationError:
+ print("Bad LD signature")
+ return HttpResponseUnauthorized("Bad signature")
+ # Otherwise, verify against the header (assuming it's the same actor)
+ else:
+ try:
+ HttpSignature.verify_request(
+ request,
+ identity.public_key,
+ )
+ except VerificationFormatError as e:
+ print("Bad HTTP signature format:", e.args[0])
+ return HttpResponseBadRequest(e.args[0])
+ except VerificationError:
+ print("Bad HTTP signature")
+ return HttpResponseUnauthorized("Bad signature")
+ # Hand off the item to be processed by the queue
+ InboxMessage.objects.create(message=document)
+ return HttpResponse(status=202)
diff --git a/users/views/identity.py b/users/views/identity.py
index d4e1155..5d11d63 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -1,33 +1,19 @@
-import json
import string
-from asgiref.sync import async_to_sync
from django import forms
from django.conf import settings
from django.contrib.auth.decorators import login_required
-from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
+from django.http import Http404
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
-from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView, TemplateView, View
from core.forms import FormHelper
-from core.ld import canonicalise
-from core.signatures import (
- HttpSignature,
- LDSignature,
- VerificationError,
- VerificationFormatError,
-)
from users.decorators import identity_required
-from users.models import Domain, Follow, Identity, IdentityStates, InboxMessage
+from users.models import Domain, Follow, Identity, IdentityStates
from users.shortcuts import by_handle_or_404
-class HttpResponseUnauthorized(HttpResponse):
- status_code = 401
-
-
class ViewIdentity(TemplateView):
template_name = "identity/view.html"
@@ -151,114 +137,3 @@ class CreateIdentity(FormView):
new_identity.users.add(self.request.user)
new_identity.generate_keypair()
return redirect(new_identity.urls.view)
-
-
-class Actor(View):
- """
- Returns the AP Actor object
- """
-
- def get(self, request, handle):
- identity = by_handle_or_404(self.request, handle)
- response = {
- "@context": [
- "https://www.w3.org/ns/activitystreams",
- "https://w3id.org/security/v1",
- ],
- "id": identity.actor_uri,
- "type": "Person",
- "inbox": identity.actor_uri + "inbox/",
- "preferredUsername": identity.username,
- "publicKey": {
- "id": identity.public_key_id,
- "owner": identity.actor_uri,
- "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")
-class Inbox(View):
- """
- AP Inbox endpoint
- """
-
- def post(self, request, handle):
- # Load the LD
- document = canonicalise(json.loads(request.body), include_security=True)
- # Find the Identity by the actor on the incoming item
- # This ensures that the signature used for the headers matches the actor
- # described in the payload.
- 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 get actor")
- return HttpResponseBadRequest("Cannot retrieve actor")
- # If there's a "signature" payload, verify against that
- if "signature" in document:
- try:
- LDSignature.verify_signature(document, identity.public_key)
- except VerificationFormatError as e:
- print("Bad LD signature format:", e.args[0])
- return HttpResponseBadRequest(e.args[0])
- except VerificationError:
- print("Bad LD signature")
- return HttpResponseUnauthorized("Bad signature")
- # Otherwise, verify against the header (assuming it's the same actor)
- else:
- try:
- HttpSignature.verify_request(
- request,
- identity.public_key,
- )
- except VerificationFormatError as e:
- print("Bad HTTP signature format:", e.args[0])
- return HttpResponseBadRequest(e.args[0])
- except VerificationError:
- print("Bad HTTP signature")
- return HttpResponseUnauthorized("Bad signature")
- # Hand off the item to be processed by the queue
- InboxMessage.objects.create(message=document)
- return HttpResponse(status=202)
-
-
-class Webfinger(View):
- """
- Services webfinger requests
- """
-
- def get(self, request):
- resource = request.GET.get("resource")
- if not resource.startswith("acct:"):
- raise Http404("Not an account resource")
- handle = resource[5:].replace("testfedi", "feditest")
- identity = by_handle_or_404(request, handle)
- return JsonResponse(
- {
- "subject": f"acct:{identity.handle}",
- "aliases": [
- identity.urls.view_short.full(),
- ],
- "links": [
- {
- "rel": "http://webfinger.net/rel/profile-page",
- "type": "text/html",
- "href": identity.urls.view_short.full(),
- },
- {
- "rel": "self",
- "type": "application/activity+json",
- "href": identity.actor_uri,
- },
- ],
- }
- )