Post URIs and host-meta

This commit is contained in:
Andrew Godwin 2022-11-12 21:14:21 -07:00
parent dd4328ae52
commit 878f56b411
12 changed files with 234 additions and 162 deletions

View File

@ -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),
),
]

View File

@ -32,7 +32,7 @@ class FanOutStates(StateGraph):
await HttpSignature.signed_request( await HttpSignature.signed_request(
uri=fan_out.identity.inbox_uri, uri=fan_out.identity.inbox_uri,
body=canonicalise(post.to_create_ap()), 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, key_id=post.author.public_key_id,
) )
return cls.sent return cls.sent

View File

@ -7,7 +7,7 @@ from django.utils import timezone
from activities.models.fan_out import FanOut from activities.models.fan_out import FanOut
from activities.models.timeline_event import TimelineEvent from activities.models.timeline_event import TimelineEvent
from core.html import sanitize_post 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 stator.models import State, StateField, StateGraph, StatorModel
from users.models.follow import Follow from users.models.follow import Follow
from users.models.identity import Identity from users.models.identity import Identity
@ -53,7 +53,7 @@ class Post(StatorModel):
local = models.BooleanField() local = models.BooleanField()
# The canonical object ID # 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 # Who should be able to see this Post
visibility = models.IntegerField( visibility = models.IntegerField(
@ -145,13 +145,17 @@ class Post(StatorModel):
""" """
# Send a copy to all people who follow this user # Send a copy to all people who follow this user
post = await self.afetch_full() 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( await FanOut.objects.acreate(
identity_id=follow.source_id, identity_id=follow.source_id,
type=FanOut.Types.post, type=FanOut.Types.post,
subject_post=post, subject_post=post,
) )
# And one for themselves # And one for themselves if they're local
if post.author.local:
await FanOut.objects.acreate( await FanOut.objects.acreate(
identity_id=post.author_id, identity_id=post.author_id,
type=FanOut.Types.post, type=FanOut.Types.post,
@ -165,7 +169,7 @@ class Post(StatorModel):
value = { value = {
"type": "Note", "type": "Note",
"id": self.object_uri, "id": self.object_uri,
"published": format_date(self.created), "published": format_ld_date(self.created),
"attributedTo": self.author.actor_uri, "attributedTo": self.author.actor_uri,
"content": self.safe_content, "content": self.safe_content,
"to": "as:Public", "to": "as:Public",
@ -190,7 +194,7 @@ class Post(StatorModel):
### ActivityPub (inbound) ### ### ActivityPub (inbound) ###
@classmethod @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. 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. Raises KeyError if it's not found and create is False.
""" """
# Do we have one with the right ID? # Do we have one with the right ID?
created = False
try: try:
return cls.objects.get(object_uri=data["id"]) post = cls.objects.get(object_uri=data["id"])
except cls.DoesNotExist: except cls.DoesNotExist:
if create: if create:
# Resolve the author # Resolve the author
author = Identity.by_actor_uri(data["attributedTo"], create=create) author = Identity.by_actor_uri(data["attributedTo"], create=create)
return cls.objects.create( post = cls.objects.create(
object_uri=data["id"],
author=author, author=author,
content=sanitize_post(data["content"]), content=sanitize_post(data["content"]),
summary=data.get("summary", None),
sensitive=data.get("as:sensitive", False),
url=data.get("url", None),
local=False, local=False,
)
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: to
# TODO: mentions # TODO: mentions
# TODO: visibility # TODO: visibility
) post.save()
else: return post
raise KeyError(f"No post with ID {data['id']}", data)
@classmethod @classmethod
def handle_create_ap(cls, data): def handle_create_ap(cls, data):
@ -227,7 +239,7 @@ class Post(StatorModel):
if data["actor"] != data["object"]["attributedTo"]: if data["actor"] != data["object"]["attributedTo"]:
raise ValueError("Create actor does not match its Post object", data) raise ValueError("Create actor does not match its Post object", data)
# Create it # 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 # Make timeline events as appropriate
for follow in Follow.objects.filter(target=post.author, source__local=True): for follow in Follow.objects.filter(target=post.author, source__local=True):
TimelineEvent.add_post(follow.source, post) TimelineEvent.add_post(follow.source, post)

View File

@ -1,6 +1,6 @@
import datetime import datetime
import urllib.parse as urllib_parse 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 import jsonld
from pyld.jsonld import JsonLdError 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) 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) 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
)

View File

@ -11,7 +11,7 @@ from django.utils.http import http_date, parse_http_date
from OpenSSL import crypto from OpenSSL import crypto
from pyld import jsonld from pyld import jsonld
from core.ld import format_date from core.ld import format_ld_date
class VerificationError(BaseException): class VerificationError(BaseException):
@ -261,7 +261,7 @@ class LDSignature:
options: Dict[str, str] = { options: Dict[str, str] = {
"@context": "https://w3id.org/identity/v1", "@context": "https://w3id.org/identity/v1",
"creator": key_id, "creator": key_id,
"created": format_date(timezone.now()), "created": format_ld_date(timezone.now()),
} }
# Get the normalised hash of each document # Get the normalised hash of each document
final_hash = cls.normalized_hash(options) + cls.normalized_hash(document) final_hash = cls.normalized_hash(options) + cls.normalized_hash(document)

View File

@ -3,7 +3,7 @@ from django.urls import path
from core import views as core from core import views as core
from stator import views as stator from stator import views as stator
from users.views import auth, identity from users.views import activitypub, auth, identity
urlpatterns = [ urlpatterns = [
path("", core.homepage), path("", core.homepage),
@ -12,15 +12,16 @@ urlpatterns = [
path("auth/logout/", auth.Logout.as_view()), path("auth/logout/", auth.Logout.as_view()),
# Identity views # Identity views
path("@<handle>/", identity.ViewIdentity.as_view()), path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/actor/", identity.Actor.as_view()), path("@<handle>/actor/", activitypub.Actor.as_view()),
path("@<handle>/actor/inbox/", identity.Inbox.as_view()), path("@<handle>/actor/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()), path("@<handle>/action/", identity.ActionIdentity.as_view()),
# Identity selection # Identity selection
path("@<handle>/activate/", identity.ActivateIdentity.as_view()), path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()), path("identity/select/", identity.SelectIdentity.as_view()),
path("identity/create/", identity.CreateIdentity.as_view()), path("identity/create/", identity.CreateIdentity.as_view()),
# Well-known endpoints # 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 # Task runner
path(".stator/runner/", stator.RequestRunner.as_view()), path(".stator/runner/", stator.RequestRunner.as_view()),
# Django admin # Django admin

View File

@ -13,7 +13,13 @@
</a> </a>
</h3> </h3>
<time> <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> </time>
<div class="content"> <div class="content">
{{ post.safe_content }} {{ post.safe_content }}

View File

@ -38,7 +38,7 @@ class FollowAdmin(admin.ModelAdmin):
@admin.register(InboxMessage) @admin.register(InboxMessage)
class InboxMessageAdmin(admin.ModelAdmin): 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"] actions = ["reset_state"]
@admin.action(description="Reset State") @admin.action(description="Reset State")

View File

@ -37,7 +37,7 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request( await HttpSignature.signed_request(
uri=follow.target.inbox_uri, uri=follow.target.inbox_uri,
body=canonicalise(follow.to_ap()), body=canonicalise(follow.to_ap()),
private_key=follow.source.public_key, private_key=follow.source.private_key,
key_id=follow.source.public_key_id, key_id=follow.source.public_key_id,
) )
return cls.local_requested return cls.local_requested
@ -57,7 +57,7 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request( await HttpSignature.signed_request(
uri=follow.source.inbox_uri, uri=follow.source.inbox_uri,
body=canonicalise(follow.to_accept_ap()), 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, key_id=follow.target.public_key_id,
) )
return cls.accepted return cls.accepted
@ -71,7 +71,7 @@ class FollowStates(StateGraph):
await HttpSignature.signed_request( await HttpSignature.signed_request(
uri=follow.target.inbox_uri, uri=follow.target.inbox_uri,
body=canonicalise(follow.to_undo_ap()), 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, key_id=follow.source.public_key_id,
) )
return cls.undone_remotely return cls.undone_remotely

View File

@ -66,3 +66,7 @@ class InboxMessage(StatorModel):
@property @property
def message_object_type(self): def message_object_type(self):
return self.message["object"]["type"].lower() return self.message["object"]["type"].lower()
@property
def message_actor(self):
return self.message.get("actor")

148
users/views/activitypub.py Normal file
View File

@ -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)

View File

@ -1,33 +1,19 @@
import json
import string import string
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
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse from django.http import Http404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView, TemplateView, View 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.signatures import (
HttpSignature,
LDSignature,
VerificationError,
VerificationFormatError,
)
from users.decorators import identity_required 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 from users.shortcuts import by_handle_or_404
class HttpResponseUnauthorized(HttpResponse):
status_code = 401
class ViewIdentity(TemplateView): class ViewIdentity(TemplateView):
template_name = "identity/view.html" template_name = "identity/view.html"
@ -151,114 +137,3 @@ class CreateIdentity(FormView):
new_identity.users.add(self.request.user) new_identity.users.add(self.request.user)
new_identity.generate_keypair() new_identity.generate_keypair()
return redirect(new_identity.urls.view) 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,
},
],
}
)