Post URIs and host-meta
This commit is contained in:
parent
dd4328ae52
commit
878f56b411
18
activities/migrations/0003_alter_post_object_uri.py
Normal file
18
activities/migrations/0003_alter_post_object_uri.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
12
core/ld.py
12
core/ld.py
@ -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
|
||||||
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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 }}
|
||||||
|
@ -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")
|
||||||
|
@ -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
|
||||||
|
@ -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
148
users/views/activitypub.py
Normal 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)
|
@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
Reference in New Issue
Block a user