2022-11-05 23:07:38 -07:00
|
|
|
import json
|
2022-11-05 19:10:39 -07:00
|
|
|
import string
|
|
|
|
|
2022-11-06 12:48:04 -08:00
|
|
|
from asgiref.sync import async_to_sync
|
2022-11-05 13:17:27 -07:00
|
|
|
from django import forms
|
|
|
|
from django.conf import settings
|
|
|
|
from django.contrib.auth.decorators import login_required
|
2022-11-06 20:30:07 -08:00
|
|
|
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
|
2022-11-05 13:17:27 -07:00
|
|
|
from django.shortcuts import redirect
|
2022-11-06 20:30:07 -08:00
|
|
|
from django.utils import timezone
|
2022-11-05 13:17:27 -07:00
|
|
|
from django.utils.decorators import method_decorator
|
2022-11-06 20:30:07 -08:00
|
|
|
from django.utils.http import parse_http_date
|
2022-11-05 16:51:54 -07:00
|
|
|
from django.views.decorators.csrf import csrf_exempt
|
2022-11-05 13:17:27 -07:00
|
|
|
from django.views.generic import FormView, TemplateView, View
|
|
|
|
|
|
|
|
from core.forms import FormHelper
|
2022-11-05 23:07:38 -07:00
|
|
|
from core.ld import canonicalise
|
2022-11-06 12:48:04 -08:00
|
|
|
from core.signatures import HttpSignature
|
2022-11-06 20:30:07 -08:00
|
|
|
from users.decorators import identity_required
|
|
|
|
from users.models import Domain, Follow, Identity
|
2022-11-05 13:17:27 -07:00
|
|
|
from users.shortcuts import by_handle_or_404
|
|
|
|
|
|
|
|
|
|
|
|
class ViewIdentity(TemplateView):
|
|
|
|
|
|
|
|
template_name = "identity/view.html"
|
|
|
|
|
|
|
|
def get_context_data(self, handle):
|
2022-11-06 20:30:07 -08:00
|
|
|
identity = by_handle_or_404(
|
|
|
|
self.request,
|
|
|
|
handle,
|
|
|
|
local=False,
|
|
|
|
fetch=True,
|
|
|
|
)
|
2022-11-05 13:17:27 -07:00
|
|
|
statuses = identity.statuses.all()[:100]
|
2022-11-05 21:49:25 -07:00
|
|
|
if identity.data_age > settings.IDENTITY_MAX_AGE:
|
|
|
|
Task.submit("identity_fetch", identity.handle)
|
2022-11-05 13:17:27 -07:00
|
|
|
return {
|
|
|
|
"identity": identity,
|
|
|
|
"statuses": statuses,
|
2022-11-06 20:30:07 -08:00
|
|
|
"follow": Follow.maybe_get(self.request.identity, identity)
|
|
|
|
if self.request.identity
|
|
|
|
else None,
|
2022-11-05 13:17:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2022-11-06 20:30:07 -08:00
|
|
|
@method_decorator(identity_required, name="dispatch")
|
|
|
|
class ActionIdentity(View):
|
|
|
|
def post(self, request, handle):
|
|
|
|
identity = by_handle_or_404(self.request, handle, local=False)
|
|
|
|
# See what action we should perform
|
|
|
|
action = self.request.POST["action"]
|
|
|
|
if action == "follow":
|
|
|
|
existing_follow = Follow.maybe_get(self.request.identity, identity)
|
|
|
|
if not existing_follow:
|
|
|
|
Follow.create_local(self.request.identity, identity)
|
|
|
|
else:
|
|
|
|
raise ValueError(f"Cannot handle identity action {action}")
|
|
|
|
return redirect(identity.urls.view)
|
|
|
|
|
|
|
|
|
2022-11-05 13:17:27 -07:00
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
|
|
class SelectIdentity(TemplateView):
|
|
|
|
|
|
|
|
template_name = "identity/select.html"
|
|
|
|
|
|
|
|
def get_context_data(self):
|
|
|
|
return {
|
|
|
|
"identities": Identity.objects.filter(users__pk=self.request.user.pk),
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2022-11-05 19:10:39 -07:00
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
|
|
class ActivateIdentity(View):
|
|
|
|
def get(self, request, handle):
|
|
|
|
identity = by_handle_or_404(request, handle)
|
|
|
|
if not identity.users.filter(pk=request.user.pk).exists():
|
|
|
|
raise Http404()
|
|
|
|
request.session["identity_id"] = identity.id
|
|
|
|
# Get next URL, not allowing offsite links
|
|
|
|
next = request.GET.get("next") or "/"
|
|
|
|
if ":" in next:
|
|
|
|
next = "/"
|
|
|
|
return redirect("/")
|
|
|
|
|
|
|
|
|
2022-11-05 13:17:27 -07:00
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
|
|
class CreateIdentity(FormView):
|
|
|
|
|
|
|
|
template_name = "identity/create.html"
|
|
|
|
|
|
|
|
class form_class(forms.Form):
|
2022-11-06 12:48:04 -08:00
|
|
|
username = forms.CharField()
|
2022-11-05 13:17:27 -07:00
|
|
|
name = forms.CharField()
|
|
|
|
|
|
|
|
helper = FormHelper(submit_text="Create")
|
|
|
|
|
2022-11-06 12:48:04 -08:00
|
|
|
def __init__(self, user, *args, **kwargs):
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.fields["domain"] = forms.ChoiceField(
|
|
|
|
choices=[
|
|
|
|
(domain.domain, domain.domain)
|
|
|
|
for domain in Domain.available_for_user(user)
|
|
|
|
]
|
|
|
|
)
|
|
|
|
|
|
|
|
def clean_username(self):
|
2022-11-05 13:17:27 -07:00
|
|
|
# Remove any leading @
|
2022-11-06 12:48:04 -08:00
|
|
|
value = self.cleaned_data["username"].lstrip("@")
|
2022-11-05 19:10:39 -07:00
|
|
|
# Validate it's all ascii characters
|
|
|
|
for character in value:
|
|
|
|
if character not in string.ascii_letters + string.digits + "_-":
|
|
|
|
raise forms.ValidationError(
|
|
|
|
"Only the letters a-z, numbers 0-9, dashes and underscores are allowed."
|
|
|
|
)
|
2022-11-05 13:17:27 -07:00
|
|
|
return value
|
|
|
|
|
2022-11-06 12:48:04 -08:00
|
|
|
def clean(self):
|
|
|
|
# Check for existing users
|
|
|
|
username = self.cleaned_data["username"]
|
|
|
|
domain = self.cleaned_data["domain"]
|
|
|
|
if Identity.objects.filter(username=username, domain=domain).exists():
|
|
|
|
raise forms.ValidationError(f"{username}@{domain} is already taken")
|
|
|
|
|
|
|
|
def get_form(self):
|
|
|
|
form_class = self.get_form_class()
|
|
|
|
return form_class(user=self.request.user, **self.get_form_kwargs())
|
|
|
|
|
2022-11-05 13:17:27 -07:00
|
|
|
def form_valid(self, form):
|
2022-11-06 12:48:04 -08:00
|
|
|
username = form.cleaned_data["username"]
|
|
|
|
domain = form.cleaned_data["domain"]
|
2022-11-06 23:19:00 -08:00
|
|
|
domain_instance = Domain.get_local_domain(domain)
|
2022-11-05 13:17:27 -07:00
|
|
|
new_identity = Identity.objects.create(
|
2022-11-06 23:19:00 -08:00
|
|
|
actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/actor/",
|
2022-11-06 12:48:04 -08:00
|
|
|
username=username,
|
|
|
|
domain_id=domain,
|
2022-11-05 13:17:27 -07:00
|
|
|
name=form.cleaned_data["name"],
|
|
|
|
local=True,
|
|
|
|
)
|
|
|
|
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)
|
2022-11-06 12:48:04 -08:00
|
|
|
response = {
|
|
|
|
"@context": [
|
|
|
|
"https://www.w3.org/ns/activitystreams",
|
|
|
|
"https://w3id.org/security/v1",
|
|
|
|
],
|
2022-11-06 23:19:00 -08:00
|
|
|
"id": identity.actor_uri,
|
2022-11-06 12:48:04 -08:00
|
|
|
"type": "Person",
|
2022-11-06 23:19:00 -08:00
|
|
|
"inbox": identity.actor_uri + "inbox/",
|
2022-11-06 12:48:04 -08:00
|
|
|
"preferredUsername": identity.username,
|
|
|
|
"publicKey": {
|
2022-11-06 23:19:00 -08:00
|
|
|
"id": identity.key_id,
|
|
|
|
"owner": identity.actor_uri,
|
2022-11-06 12:48:04 -08:00
|
|
|
"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))
|
2022-11-05 13:17:27 -07:00
|
|
|
|
|
|
|
|
2022-11-05 16:51:54 -07:00
|
|
|
@method_decorator(csrf_exempt, name="dispatch")
|
|
|
|
class Inbox(View):
|
|
|
|
"""
|
|
|
|
AP Inbox endpoint
|
|
|
|
"""
|
|
|
|
|
|
|
|
def post(self, request, handle):
|
2022-11-06 12:48:04 -08:00
|
|
|
# Verify body digest
|
|
|
|
if "HTTP_DIGEST" in request.META:
|
|
|
|
expected_digest = HttpSignature.calculate_digest(request.body)
|
|
|
|
if request.META["HTTP_DIGEST"] != expected_digest:
|
2022-11-06 20:30:07 -08:00
|
|
|
return HttpResponseBadRequest("Digest is incorrect")
|
|
|
|
# Verify date header
|
|
|
|
if "HTTP_DATE" in request.META:
|
|
|
|
header_date = parse_http_date(request.META["HTTP_DATE"])
|
|
|
|
if abs(timezone.now().timestamp() - header_date) > 60:
|
|
|
|
return HttpResponseBadRequest("Date is too far away")
|
2022-11-06 12:48:04 -08:00
|
|
|
# Get the signature details
|
2022-11-05 23:07:38 -07:00
|
|
|
if "HTTP_SIGNATURE" not in request.META:
|
2022-11-06 20:30:07 -08:00
|
|
|
return HttpResponseBadRequest("No signature present")
|
2022-11-06 12:48:04 -08:00
|
|
|
signature_details = HttpSignature.parse_signature(
|
|
|
|
request.META["HTTP_SIGNATURE"]
|
|
|
|
)
|
2022-11-05 23:07:38 -07:00
|
|
|
# Reject unknown algorithms
|
|
|
|
if signature_details["algorithm"] != "rsa-sha256":
|
2022-11-06 20:30:07 -08:00
|
|
|
return HttpResponseBadRequest("Unknown signature algorithm")
|
2022-11-05 23:07:38 -07:00
|
|
|
# Create the signature payload
|
2022-11-06 12:48:04 -08:00
|
|
|
headers_string = HttpSignature.headers_from_request(
|
|
|
|
request, signature_details["headers"]
|
|
|
|
)
|
2022-11-05 23:07:38 -07:00
|
|
|
# Load the LD
|
|
|
|
document = canonicalise(json.loads(request.body))
|
|
|
|
# Find the Identity by the actor on the incoming item
|
2022-11-06 20:30:07 -08:00
|
|
|
# This ensures that the signature used for the headers matches the actor
|
|
|
|
# described in the payload.
|
2022-11-06 13:14:08 -08:00
|
|
|
identity = Identity.by_actor_uri_with_create(document["actor"])
|
2022-11-06 12:48:04 -08:00
|
|
|
if not identity.public_key:
|
|
|
|
# See if we can fetch it right now
|
|
|
|
async_to_sync(identity.fetch_actor)()
|
|
|
|
if not identity.public_key:
|
|
|
|
return HttpResponseBadRequest("Cannot retrieve actor")
|
|
|
|
if not identity.verify_signature(
|
|
|
|
signature_details["signature"], headers_string
|
|
|
|
):
|
2022-11-06 20:30:07 -08:00
|
|
|
return HttpResponseBadRequest("Bad signature")
|
|
|
|
# Hand off the item to be processed by the queue
|
|
|
|
Task.submit("inbox_item", subject=identity.actor_uri, payload=document)
|
|
|
|
return HttpResponse(status=202)
|
2022-11-05 16:51:54 -07:00
|
|
|
|
|
|
|
|
2022-11-05 13:17:27 -07:00
|
|
|
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")
|
2022-11-06 12:48:04 -08:00
|
|
|
handle = resource[5:].replace("testfedi", "feditest")
|
2022-11-05 13:17:27 -07:00
|
|
|
identity = by_handle_or_404(request, handle)
|
|
|
|
return JsonResponse(
|
|
|
|
{
|
|
|
|
"subject": f"acct:{identity.handle}",
|
|
|
|
"aliases": [
|
2022-11-06 12:48:04 -08:00
|
|
|
identity.urls.view_short.full(),
|
2022-11-05 13:17:27 -07:00
|
|
|
],
|
|
|
|
"links": [
|
|
|
|
{
|
|
|
|
"rel": "http://webfinger.net/rel/profile-page",
|
|
|
|
"type": "text/html",
|
2022-11-06 12:48:04 -08:00
|
|
|
"href": identity.urls.view_short.full(),
|
2022-11-05 13:17:27 -07:00
|
|
|
},
|
|
|
|
{
|
|
|
|
"rel": "self",
|
|
|
|
"type": "application/activity+json",
|
2022-11-06 23:19:00 -08:00
|
|
|
"href": identity.actor_uri,
|
2022-11-05 13:17:27 -07:00
|
|
|
},
|
|
|
|
],
|
|
|
|
}
|
|
|
|
)
|