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("@/", identity.ViewIdentity.as_view()), - path("@/actor/", identity.Actor.as_view()), - path("@/actor/inbox/", identity.Inbox.as_view()), + path("@/actor/", activitypub.Actor.as_view()), + path("@/actor/inbox/", activitypub.Inbox.as_view()), path("@/action/", identity.ActionIdentity.as_view()), # Identity selection path("@/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 @@
{{ 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( + """ + + + """ + % 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, - }, - ], - } - )