mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-26 01:01:00 +00:00
Get Actor fetching and parsing working
This commit is contained in:
parent
57e33f1215
commit
e44a321ec5
13 changed files with 567 additions and 18 deletions
|
@ -1,6 +1,12 @@
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from pyld import jsonld
|
||||||
|
|
||||||
|
from core.ld import builtin_document_loader
|
||||||
|
|
||||||
|
|
||||||
class CoreConfig(AppConfig):
|
class CoreConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "core"
|
name = "core"
|
||||||
|
|
||||||
|
def ready(self) -> None:
|
||||||
|
jsonld.set_document_loader(builtin_document_loader)
|
||||||
|
|
302
core/ld.py
Normal file
302
core/ld.py
Normal file
|
@ -0,0 +1,302 @@
|
||||||
|
import urllib.parse as urllib_parse
|
||||||
|
|
||||||
|
from pyld import jsonld
|
||||||
|
from pyld.jsonld import JsonLdError
|
||||||
|
|
||||||
|
schemas = {
|
||||||
|
"www.w3.org/ns/activitystreams": {
|
||||||
|
"contentType": "application/ld+json",
|
||||||
|
"documentUrl": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"contextUrl": None,
|
||||||
|
"document": {
|
||||||
|
"@context": {
|
||||||
|
"@vocab": "_:",
|
||||||
|
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
||||||
|
"as": "https://www.w3.org/ns/activitystreams#",
|
||||||
|
"ldp": "http://www.w3.org/ns/ldp#",
|
||||||
|
"vcard": "http://www.w3.org/2006/vcard/ns#",
|
||||||
|
"id": "@id",
|
||||||
|
"type": "@type",
|
||||||
|
"Accept": "as:Accept",
|
||||||
|
"Activity": "as:Activity",
|
||||||
|
"IntransitiveActivity": "as:IntransitiveActivity",
|
||||||
|
"Add": "as:Add",
|
||||||
|
"Announce": "as:Announce",
|
||||||
|
"Application": "as:Application",
|
||||||
|
"Arrive": "as:Arrive",
|
||||||
|
"Article": "as:Article",
|
||||||
|
"Audio": "as:Audio",
|
||||||
|
"Block": "as:Block",
|
||||||
|
"Collection": "as:Collection",
|
||||||
|
"CollectionPage": "as:CollectionPage",
|
||||||
|
"Relationship": "as:Relationship",
|
||||||
|
"Create": "as:Create",
|
||||||
|
"Delete": "as:Delete",
|
||||||
|
"Dislike": "as:Dislike",
|
||||||
|
"Document": "as:Document",
|
||||||
|
"Event": "as:Event",
|
||||||
|
"Follow": "as:Follow",
|
||||||
|
"Flag": "as:Flag",
|
||||||
|
"Group": "as:Group",
|
||||||
|
"Ignore": "as:Ignore",
|
||||||
|
"Image": "as:Image",
|
||||||
|
"Invite": "as:Invite",
|
||||||
|
"Join": "as:Join",
|
||||||
|
"Leave": "as:Leave",
|
||||||
|
"Like": "as:Like",
|
||||||
|
"Link": "as:Link",
|
||||||
|
"Mention": "as:Mention",
|
||||||
|
"Note": "as:Note",
|
||||||
|
"Object": "as:Object",
|
||||||
|
"Offer": "as:Offer",
|
||||||
|
"OrderedCollection": "as:OrderedCollection",
|
||||||
|
"OrderedCollectionPage": "as:OrderedCollectionPage",
|
||||||
|
"Organization": "as:Organization",
|
||||||
|
"Page": "as:Page",
|
||||||
|
"Person": "as:Person",
|
||||||
|
"Place": "as:Place",
|
||||||
|
"Profile": "as:Profile",
|
||||||
|
"Question": "as:Question",
|
||||||
|
"Reject": "as:Reject",
|
||||||
|
"Remove": "as:Remove",
|
||||||
|
"Service": "as:Service",
|
||||||
|
"TentativeAccept": "as:TentativeAccept",
|
||||||
|
"TentativeReject": "as:TentativeReject",
|
||||||
|
"Tombstone": "as:Tombstone",
|
||||||
|
"Undo": "as:Undo",
|
||||||
|
"Update": "as:Update",
|
||||||
|
"Video": "as:Video",
|
||||||
|
"View": "as:View",
|
||||||
|
"Listen": "as:Listen",
|
||||||
|
"Read": "as:Read",
|
||||||
|
"Move": "as:Move",
|
||||||
|
"Travel": "as:Travel",
|
||||||
|
"IsFollowing": "as:IsFollowing",
|
||||||
|
"IsFollowedBy": "as:IsFollowedBy",
|
||||||
|
"IsContact": "as:IsContact",
|
||||||
|
"IsMember": "as:IsMember",
|
||||||
|
"subject": {"@id": "as:subject", "@type": "@id"},
|
||||||
|
"relationship": {"@id": "as:relationship", "@type": "@id"},
|
||||||
|
"actor": {"@id": "as:actor", "@type": "@id"},
|
||||||
|
"attributedTo": {"@id": "as:attributedTo", "@type": "@id"},
|
||||||
|
"attachment": {"@id": "as:attachment", "@type": "@id"},
|
||||||
|
"bcc": {"@id": "as:bcc", "@type": "@id"},
|
||||||
|
"bto": {"@id": "as:bto", "@type": "@id"},
|
||||||
|
"cc": {"@id": "as:cc", "@type": "@id"},
|
||||||
|
"context": {"@id": "as:context", "@type": "@id"},
|
||||||
|
"current": {"@id": "as:current", "@type": "@id"},
|
||||||
|
"first": {"@id": "as:first", "@type": "@id"},
|
||||||
|
"generator": {"@id": "as:generator", "@type": "@id"},
|
||||||
|
"icon": {"@id": "as:icon", "@type": "@id"},
|
||||||
|
"image": {"@id": "as:image", "@type": "@id"},
|
||||||
|
"inReplyTo": {"@id": "as:inReplyTo", "@type": "@id"},
|
||||||
|
"items": {"@id": "as:items", "@type": "@id"},
|
||||||
|
"instrument": {"@id": "as:instrument", "@type": "@id"},
|
||||||
|
"orderedItems": {
|
||||||
|
"@id": "as:items",
|
||||||
|
"@type": "@id",
|
||||||
|
"@container": "@list",
|
||||||
|
},
|
||||||
|
"last": {"@id": "as:last", "@type": "@id"},
|
||||||
|
"location": {"@id": "as:location", "@type": "@id"},
|
||||||
|
"next": {"@id": "as:next", "@type": "@id"},
|
||||||
|
"object": {"@id": "as:object", "@type": "@id"},
|
||||||
|
"oneOf": {"@id": "as:oneOf", "@type": "@id"},
|
||||||
|
"anyOf": {"@id": "as:anyOf", "@type": "@id"},
|
||||||
|
"closed": {"@id": "as:closed", "@type": "xsd:dateTime"},
|
||||||
|
"origin": {"@id": "as:origin", "@type": "@id"},
|
||||||
|
"accuracy": {"@id": "as:accuracy", "@type": "xsd:float"},
|
||||||
|
"prev": {"@id": "as:prev", "@type": "@id"},
|
||||||
|
"preview": {"@id": "as:preview", "@type": "@id"},
|
||||||
|
"replies": {"@id": "as:replies", "@type": "@id"},
|
||||||
|
"result": {"@id": "as:result", "@type": "@id"},
|
||||||
|
"audience": {"@id": "as:audience", "@type": "@id"},
|
||||||
|
"partOf": {"@id": "as:partOf", "@type": "@id"},
|
||||||
|
"tag": {"@id": "as:tag", "@type": "@id"},
|
||||||
|
"target": {"@id": "as:target", "@type": "@id"},
|
||||||
|
"to": {"@id": "as:to", "@type": "@id"},
|
||||||
|
"url": {"@id": "as:url", "@type": "@id"},
|
||||||
|
"altitude": {"@id": "as:altitude", "@type": "xsd:float"},
|
||||||
|
"content": "as:content",
|
||||||
|
"contentMap": {"@id": "as:content", "@container": "@language"},
|
||||||
|
"name": "as:name",
|
||||||
|
"nameMap": {"@id": "as:name", "@container": "@language"},
|
||||||
|
"duration": {"@id": "as:duration", "@type": "xsd:duration"},
|
||||||
|
"endTime": {"@id": "as:endTime", "@type": "xsd:dateTime"},
|
||||||
|
"height": {"@id": "as:height", "@type": "xsd:nonNegativeInteger"},
|
||||||
|
"href": {"@id": "as:href", "@type": "@id"},
|
||||||
|
"hreflang": "as:hreflang",
|
||||||
|
"latitude": {"@id": "as:latitude", "@type": "xsd:float"},
|
||||||
|
"longitude": {"@id": "as:longitude", "@type": "xsd:float"},
|
||||||
|
"mediaType": "as:mediaType",
|
||||||
|
"published": {"@id": "as:published", "@type": "xsd:dateTime"},
|
||||||
|
"radius": {"@id": "as:radius", "@type": "xsd:float"},
|
||||||
|
"rel": "as:rel",
|
||||||
|
"startIndex": {
|
||||||
|
"@id": "as:startIndex",
|
||||||
|
"@type": "xsd:nonNegativeInteger",
|
||||||
|
},
|
||||||
|
"startTime": {"@id": "as:startTime", "@type": "xsd:dateTime"},
|
||||||
|
"summary": "as:summary",
|
||||||
|
"summaryMap": {"@id": "as:summary", "@container": "@language"},
|
||||||
|
"totalItems": {
|
||||||
|
"@id": "as:totalItems",
|
||||||
|
"@type": "xsd:nonNegativeInteger",
|
||||||
|
},
|
||||||
|
"units": "as:units",
|
||||||
|
"updated": {"@id": "as:updated", "@type": "xsd:dateTime"},
|
||||||
|
"width": {"@id": "as:width", "@type": "xsd:nonNegativeInteger"},
|
||||||
|
"describes": {"@id": "as:describes", "@type": "@id"},
|
||||||
|
"formerType": {"@id": "as:formerType", "@type": "@id"},
|
||||||
|
"deleted": {"@id": "as:deleted", "@type": "xsd:dateTime"},
|
||||||
|
"inbox": {"@id": "ldp:inbox", "@type": "@id"},
|
||||||
|
"outbox": {"@id": "as:outbox", "@type": "@id"},
|
||||||
|
"following": {"@id": "as:following", "@type": "@id"},
|
||||||
|
"followers": {"@id": "as:followers", "@type": "@id"},
|
||||||
|
"streams": {"@id": "as:streams", "@type": "@id"},
|
||||||
|
"preferredUsername": "as:preferredUsername",
|
||||||
|
"endpoints": {"@id": "as:endpoints", "@type": "@id"},
|
||||||
|
"uploadMedia": {"@id": "as:uploadMedia", "@type": "@id"},
|
||||||
|
"proxyUrl": {"@id": "as:proxyUrl", "@type": "@id"},
|
||||||
|
"liked": {"@id": "as:liked", "@type": "@id"},
|
||||||
|
"oauthAuthorizationEndpoint": {
|
||||||
|
"@id": "as:oauthAuthorizationEndpoint",
|
||||||
|
"@type": "@id",
|
||||||
|
},
|
||||||
|
"oauthTokenEndpoint": {"@id": "as:oauthTokenEndpoint", "@type": "@id"},
|
||||||
|
"provideClientKey": {"@id": "as:provideClientKey", "@type": "@id"},
|
||||||
|
"signClientKey": {"@id": "as:signClientKey", "@type": "@id"},
|
||||||
|
"sharedInbox": {"@id": "as:sharedInbox", "@type": "@id"},
|
||||||
|
"Public": {"@id": "as:Public", "@type": "@id"},
|
||||||
|
"source": "as:source",
|
||||||
|
"likes": {"@id": "as:likes", "@type": "@id"},
|
||||||
|
"shares": {"@id": "as:shares", "@type": "@id"},
|
||||||
|
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"w3id.org/security/v1": {
|
||||||
|
"contentType": "application/ld+json",
|
||||||
|
"documentUrl": "https://w3id.org/security/v1",
|
||||||
|
"contextUrl": None,
|
||||||
|
"document": {
|
||||||
|
"@context": {
|
||||||
|
"id": "@id",
|
||||||
|
"type": "@type",
|
||||||
|
"dc": "http://purl.org/dc/terms/",
|
||||||
|
"sec": "https://w3id.org/security#",
|
||||||
|
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
||||||
|
"EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016",
|
||||||
|
"Ed25519Signature2018": "sec:Ed25519Signature2018",
|
||||||
|
"EncryptedMessage": "sec:EncryptedMessage",
|
||||||
|
"GraphSignature2012": "sec:GraphSignature2012",
|
||||||
|
"LinkedDataSignature2015": "sec:LinkedDataSignature2015",
|
||||||
|
"LinkedDataSignature2016": "sec:LinkedDataSignature2016",
|
||||||
|
"CryptographicKey": "sec:Key",
|
||||||
|
"authenticationTag": "sec:authenticationTag",
|
||||||
|
"canonicalizationAlgorithm": "sec:canonicalizationAlgorithm",
|
||||||
|
"cipherAlgorithm": "sec:cipherAlgorithm",
|
||||||
|
"cipherData": "sec:cipherData",
|
||||||
|
"cipherKey": "sec:cipherKey",
|
||||||
|
"created": {"@id": "dc:created", "@type": "xsd:dateTime"},
|
||||||
|
"creator": {"@id": "dc:creator", "@type": "@id"},
|
||||||
|
"digestAlgorithm": "sec:digestAlgorithm",
|
||||||
|
"digestValue": "sec:digestValue",
|
||||||
|
"domain": "sec:domain",
|
||||||
|
"encryptionKey": "sec:encryptionKey",
|
||||||
|
"expiration": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
|
||||||
|
"expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"},
|
||||||
|
"initializationVector": "sec:initializationVector",
|
||||||
|
"iterationCount": "sec:iterationCount",
|
||||||
|
"nonce": "sec:nonce",
|
||||||
|
"normalizationAlgorithm": "sec:normalizationAlgorithm",
|
||||||
|
"owner": {"@id": "sec:owner", "@type": "@id"},
|
||||||
|
"password": "sec:password",
|
||||||
|
"privateKey": {"@id": "sec:privateKey", "@type": "@id"},
|
||||||
|
"privateKeyPem": "sec:privateKeyPem",
|
||||||
|
"publicKey": {"@id": "sec:publicKey", "@type": "@id"},
|
||||||
|
"publicKeyBase58": "sec:publicKeyBase58",
|
||||||
|
"publicKeyPem": "sec:publicKeyPem",
|
||||||
|
"publicKeyWif": "sec:publicKeyWif",
|
||||||
|
"publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"},
|
||||||
|
"revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"},
|
||||||
|
"salt": "sec:salt",
|
||||||
|
"signature": "sec:signature",
|
||||||
|
"signatureAlgorithm": "sec:signingAlgorithm",
|
||||||
|
"signatureValue": "sec:signatureValue",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def builtin_document_loader(url: str, options={}):
|
||||||
|
# Get URL without scheme
|
||||||
|
pieces = urllib_parse.urlparse(url)
|
||||||
|
if pieces.hostname is None:
|
||||||
|
raise JsonLdError(
|
||||||
|
f"No schema built-in for {url!r}",
|
||||||
|
"jsonld.LoadDocumentError",
|
||||||
|
code="loading document failed",
|
||||||
|
cause="NoHostnameError",
|
||||||
|
)
|
||||||
|
key = pieces.hostname + pieces.path.rstrip("/")
|
||||||
|
try:
|
||||||
|
return schemas[key]
|
||||||
|
except KeyError:
|
||||||
|
raise JsonLdError(
|
||||||
|
f"No schema built-in for {key!r}",
|
||||||
|
"jsonld.LoadDocumentError",
|
||||||
|
code="loading document failed",
|
||||||
|
cause="KeyError",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LDDocument:
|
||||||
|
"""
|
||||||
|
Utility class for dealing with a document a bit more easily
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, json_data):
|
||||||
|
self.items = {}
|
||||||
|
for entry in jsonld.flatten(jsonld.expand(json_data)):
|
||||||
|
item = LDItem(self, entry)
|
||||||
|
self.items[item.id] = item
|
||||||
|
|
||||||
|
def by_type(self, type):
|
||||||
|
for item in self.items.values():
|
||||||
|
if item.type == type:
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
|
class LDItem:
|
||||||
|
"""
|
||||||
|
Represents a single item in an LDDocument
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, document, data):
|
||||||
|
self.data = data
|
||||||
|
self.document = document
|
||||||
|
self.id = self.data["@id"]
|
||||||
|
if "@type" in self.data:
|
||||||
|
self.type = self.data["@type"][0]
|
||||||
|
else:
|
||||||
|
self.type = None
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
"""
|
||||||
|
Gets the first value of the given key, or None if it's not present.
|
||||||
|
If it's an ID reference, returns the other Item if possible, or the raw
|
||||||
|
ID if it's not supplied.
|
||||||
|
"""
|
||||||
|
contents = self.data.get(key)
|
||||||
|
if not contents:
|
||||||
|
return None
|
||||||
|
id = contents[0].get("@id")
|
||||||
|
value = contents[0].get("@value")
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
if id in self.document.items:
|
||||||
|
return self.document.items[id]
|
||||||
|
else:
|
||||||
|
return id
|
|
@ -4,3 +4,4 @@ pillow~=9.3.0
|
||||||
urlman~=2.0.1
|
urlman~=2.0.1
|
||||||
django-crispy-forms~=1.14
|
django-crispy-forms~=1.14
|
||||||
cryptography~=38.0
|
cryptography~=38.0
|
||||||
|
httpx~=0.23
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 4.1.3 on 2022-11-05 19:43
|
# Generated by Django 4.1.3 on 2022-11-05 23:50
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import urlman
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
@ -33,3 +34,6 @@ class Status(models.Model):
|
||||||
text=text,
|
text=text,
|
||||||
local=True,
|
local=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class urls(urlman.Urls):
|
||||||
|
view = "{self.identity.urls.view}{self.id}/"
|
||||||
|
|
|
@ -12,11 +12,12 @@ urlpatterns = [
|
||||||
# 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/", identity.Actor.as_view()),
|
||||||
|
path("@<handle>/actor/inbox/", identity.Inbox.as_view()),
|
||||||
# Identity selection
|
# Identity selection
|
||||||
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", identity.Webfinger.as_view()),
|
||||||
# Django admin
|
# Django admin
|
||||||
path("djadmin/", admin.site.urls),
|
path("djadmin/", admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
<small>{{ status.identity.short_handle }}</small>
|
<small>{{ status.identity.short_handle }}</small>
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<time>{{ status.created | timesince }} ago</time>
|
<time>
|
||||||
|
<a href="{{ status.urls.view }}">{{ status.created | timesince }} ago</a>
|
||||||
|
</time>
|
||||||
{{ status.text | linebreaks }}
|
{{ status.text | linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 4.1.3 on 2022-11-05 19:15
|
# Generated by Django 4.1.3 on 2022-11-05 23:50
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
|
@ -96,32 +96,50 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
("handle", models.CharField(max_length=500, unique=True)),
|
("handle", models.CharField(max_length=500, unique=True)),
|
||||||
("name", models.CharField(blank=True, max_length=500, null=True)),
|
("name", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
("bio", models.TextField(blank=True, null=True)),
|
("summary", models.TextField(blank=True, null=True)),
|
||||||
|
("actor_uri", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
(
|
(
|
||||||
"profile_image",
|
"profile_uri",
|
||||||
|
models.CharField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
("inbox_uri", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
("outbox_uri", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
("icon_uri", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
("image_uri", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
(
|
||||||
|
"icon",
|
||||||
models.ImageField(
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
upload_to=functools.partial(
|
upload_to=functools.partial(
|
||||||
users.models.identity.upload_namer,
|
users.models.identity.upload_namer,
|
||||||
*("profile_images",),
|
*("profile_images",),
|
||||||
**{},
|
**{},
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"background_image",
|
"image",
|
||||||
models.ImageField(
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
upload_to=functools.partial(
|
upload_to=functools.partial(
|
||||||
users.models.identity.upload_namer,
|
users.models.identity.upload_namer,
|
||||||
*("background_images",),
|
*("background_images",),
|
||||||
**{},
|
**{},
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("local", models.BooleanField()),
|
("local", models.BooleanField()),
|
||||||
("private_key", models.BinaryField(blank=True, null=True)),
|
(
|
||||||
("public_key", models.BinaryField(blank=True, null=True)),
|
"manually_approves_followers",
|
||||||
|
models.BooleanField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
("private_key", models.TextField(blank=True, null=True)),
|
||||||
|
("public_key", models.TextField(blank=True, null=True)),
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated", models.DateTimeField(auto_now=True)),
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
|
("fetched", models.DateTimeField(blank=True, null=True)),
|
||||||
("deleted", models.DateTimeField(blank=True, null=True)),
|
("deleted", models.DateTimeField(blank=True, null=True)),
|
||||||
(
|
(
|
||||||
"users",
|
"users",
|
||||||
|
@ -131,4 +149,37 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Follow",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("note", models.TextField(blank=True, null=True)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"source",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="outbound_follows",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="inbound_follows",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from .follow import Follow # noqa
|
||||||
from .identity import Identity # noqa
|
from .identity import Identity # noqa
|
||||||
from .user import User # noqa
|
from .user import User # noqa
|
||||||
from .user_event import UserEvent # noqa
|
from .user_event import UserEvent # noqa
|
||||||
|
|
23
users/models/follow.py
Normal file
23
users/models/follow.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Follow(models.Model):
|
||||||
|
"""
|
||||||
|
Tracks major events that happen to users
|
||||||
|
"""
|
||||||
|
|
||||||
|
source = models.ForeignKey(
|
||||||
|
"users.Identity",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="outbound_follows",
|
||||||
|
)
|
||||||
|
target = models.ForeignKey(
|
||||||
|
"users.Identity",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="inbound_follows",
|
||||||
|
)
|
||||||
|
|
||||||
|
note = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
|
@ -2,12 +2,17 @@ import base64
|
||||||
import uuid
|
import uuid
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
|
import httpx
|
||||||
import urlman
|
import urlman
|
||||||
from cryptography.hazmat.primitives import serialization
|
from asgiref.sync import sync_to_async
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.http import http_date
|
||||||
|
|
||||||
|
from core.ld import LDDocument
|
||||||
|
|
||||||
|
|
||||||
def upload_namer(prefix, instance, filename):
|
def upload_namer(prefix, instance, filename):
|
||||||
|
@ -27,20 +32,31 @@ class Identity(models.Model):
|
||||||
# The handle includes the domain!
|
# The handle includes the domain!
|
||||||
handle = models.CharField(max_length=500, unique=True)
|
handle = models.CharField(max_length=500, unique=True)
|
||||||
name = models.CharField(max_length=500, blank=True, null=True)
|
name = models.CharField(max_length=500, blank=True, null=True)
|
||||||
bio = models.TextField(blank=True, null=True)
|
summary = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
profile_image = models.ImageField(upload_to=partial(upload_namer, "profile_images"))
|
actor_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||||
background_image = models.ImageField(
|
profile_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||||
upload_to=partial(upload_namer, "background_images")
|
inbox_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
outbox_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
icon_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
image_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
|
||||||
|
icon = models.ImageField(
|
||||||
|
upload_to=partial(upload_namer, "profile_images"), blank=True, null=True
|
||||||
|
)
|
||||||
|
image = models.ImageField(
|
||||||
|
upload_to=partial(upload_namer, "background_images"), blank=True, null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
local = models.BooleanField()
|
local = models.BooleanField()
|
||||||
users = models.ManyToManyField("users.User", related_name="identities")
|
users = models.ManyToManyField("users.User", related_name="identities")
|
||||||
|
manually_approves_followers = models.BooleanField(blank=True, null=True)
|
||||||
private_key = models.TextField(null=True, blank=True)
|
private_key = models.TextField(null=True, blank=True)
|
||||||
public_key = models.TextField(null=True, blank=True)
|
public_key = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
updated = models.DateTimeField(auto_now=True)
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
fetched = models.DateTimeField(null=True, blank=True)
|
||||||
deleted = models.DateTimeField(null=True, blank=True)
|
deleted = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -69,6 +85,128 @@ class Identity(models.Model):
|
||||||
)
|
)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
async def fetch_details(self):
|
||||||
|
if self.local:
|
||||||
|
raise ValueError("Cannot fetch local identities")
|
||||||
|
self.actor_uri = None
|
||||||
|
self.inbox_uri = None
|
||||||
|
self.profile_uri = None
|
||||||
|
# Go knock on webfinger and see what their address is
|
||||||
|
await self.fetch_webfinger()
|
||||||
|
# Fetch actor JSON
|
||||||
|
if self.actor_uri:
|
||||||
|
await self.fetch_actor()
|
||||||
|
self.fetched = timezone.now()
|
||||||
|
await sync_to_async(self.save)()
|
||||||
|
|
||||||
|
async def fetch_webfinger(self) -> bool:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"https://{self.domain}/.well-known/webfinger?resource=acct:{self.handle}",
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return False
|
||||||
|
data = response.json()
|
||||||
|
for link in data["links"]:
|
||||||
|
if (
|
||||||
|
link.get("type") == "application/activity+json"
|
||||||
|
and link.get("rel") == "self"
|
||||||
|
):
|
||||||
|
self.actor_uri = link["href"]
|
||||||
|
elif (
|
||||||
|
link.get("type") == "text/html"
|
||||||
|
and link.get("rel") == "http://webfinger.net/rel/profile-page"
|
||||||
|
):
|
||||||
|
self.profile_uri = link["href"]
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fetch_actor(self) -> bool:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
self.actor_uri,
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return False
|
||||||
|
data = response.json()
|
||||||
|
document = LDDocument(data)
|
||||||
|
for person in document.by_type(
|
||||||
|
"https://www.w3.org/ns/activitystreams#Person"
|
||||||
|
):
|
||||||
|
self.name = person.get("https://www.w3.org/ns/activitystreams#name")
|
||||||
|
self.summary = person.get(
|
||||||
|
"https://www.w3.org/ns/activitystreams#summary"
|
||||||
|
)
|
||||||
|
self.inbox_uri = person.get("http://www.w3.org/ns/ldp#inbox")
|
||||||
|
self.outbox_uri = person.get(
|
||||||
|
"https://www.w3.org/ns/activitystreams#outbox"
|
||||||
|
)
|
||||||
|
self.manually_approves_followers = person.get(
|
||||||
|
"https://www.w3.org/ns/activitystreams#manuallyApprovesFollowers'"
|
||||||
|
)
|
||||||
|
self.private_key = person.get(
|
||||||
|
"https://w3id.org/security#publicKey"
|
||||||
|
).get("https://w3id.org/security#publicKeyPem")
|
||||||
|
icon = person.get("https://www.w3.org/ns/activitystreams#icon")
|
||||||
|
if icon:
|
||||||
|
self.icon_uri = icon.get(
|
||||||
|
"https://www.w3.org/ns/activitystreams#url"
|
||||||
|
)
|
||||||
|
image = person.get("https://www.w3.org/ns/activitystreams#image")
|
||||||
|
if image:
|
||||||
|
self.image_uri = image.get(
|
||||||
|
"https://www.w3.org/ns/activitystreams#url"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def signed_request(self, host, method, path, document):
|
||||||
|
"""
|
||||||
|
Delivers the document to the specified host, method, path and signed
|
||||||
|
as this user.
|
||||||
|
"""
|
||||||
|
private_key = serialization.load_pem_private_key(
|
||||||
|
self.private_key,
|
||||||
|
password=None,
|
||||||
|
)
|
||||||
|
date_string = http_date(timezone.now().timestamp())
|
||||||
|
headers = {
|
||||||
|
"(request-target)": f"{method} {path}",
|
||||||
|
"Host": host,
|
||||||
|
"Date": date_string,
|
||||||
|
}
|
||||||
|
headers_string = " ".join(headers.keys())
|
||||||
|
signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items())
|
||||||
|
signature = base64.b64encode(
|
||||||
|
private_key.sign(
|
||||||
|
signed_string,
|
||||||
|
padding.PSS(
|
||||||
|
mgf=padding.MGF1(hashes.SHA256()),
|
||||||
|
salt_length=padding.PSS.MAX_LENGTH,
|
||||||
|
),
|
||||||
|
hashes.SHA256(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
del headers["(request-target)"]
|
||||||
|
headers[
|
||||||
|
"Signature"
|
||||||
|
] = f'keyId="https://{settings.DEFAULT_DOMAIN}{self.urls.actor}",headers="{headers_string}",signature="{signature}"'
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
return await client.request(
|
||||||
|
method,
|
||||||
|
"https://{host}{path}",
|
||||||
|
headers=headers,
|
||||||
|
data=document,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_signature(self, request):
|
||||||
|
"""
|
||||||
|
Attempts to validate the signature on an incoming request.
|
||||||
|
Returns False if the signature is invalid, None if it cannot be verified
|
||||||
|
as we do not have the key locally, or the name of the actor if it is valid.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name or self.handle
|
return self.name or self.handle
|
||||||
|
|
||||||
|
|
|
@ -56,3 +56,9 @@ class User(AbstractBaseUser):
|
||||||
@property
|
@property
|
||||||
def is_staff(self):
|
def is_staff(self):
|
||||||
return self.admin
|
return self.admin
|
||||||
|
|
||||||
|
def has_module_perms(self, module):
|
||||||
|
return self.admin
|
||||||
|
|
||||||
|
def has_perm(self, perm):
|
||||||
|
return self.admin
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.contrib.auth.decorators import login_required
|
||||||
from django.http import Http404, JsonResponse
|
from django.http import Http404, JsonResponse
|
||||||
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
|
||||||
|
@ -88,7 +89,7 @@ class Actor(View):
|
||||||
],
|
],
|
||||||
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
|
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
|
||||||
"type": "Person",
|
"type": "Person",
|
||||||
"preferredUsername": "alice",
|
"preferredUsername": identity.short_handle,
|
||||||
"inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}",
|
"inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}",
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key",
|
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key",
|
||||||
|
@ -99,6 +100,19 @@ class Actor(View):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
class Inbox(View):
|
||||||
|
"""
|
||||||
|
AP Inbox endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request, handle):
|
||||||
|
# Validate the signature
|
||||||
|
signature = request.META.get("HTTP_SIGNATURE")
|
||||||
|
print(signature)
|
||||||
|
print(request.body)
|
||||||
|
|
||||||
|
|
||||||
class Webfinger(View):
|
class Webfinger(View):
|
||||||
"""
|
"""
|
||||||
Services webfinger requests
|
Services webfinger requests
|
||||||
|
|
Loading…
Reference in a new issue