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(
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

View File

@ -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,13 +145,17 @@ 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
# And one for themselves if they're local
if post.author.local:
await FanOut.objects.acreate(
identity_id=post.author_id,
type=FanOut.Types.post,
@ -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,
)
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
)
else:
raise KeyError(f"No post with ID {data['id']}", data)
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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