kopia lustrzana https://gitlab.com/jaywink/federation
Refactor jsonld context management. Move LD signing to entities/activitypub. Move jsonld document_loader caching to __init__.py. Fix tests.
rodzic
913bc300df
commit
41785c2fdc
|
@ -23,7 +23,7 @@ arrow
|
||||||
freezegun
|
freezegun
|
||||||
|
|
||||||
# Django support
|
# Django support
|
||||||
django>=1.8,<2.3
|
django>=3.2,<4
|
||||||
pytest-django
|
pytest-django
|
||||||
|
|
||||||
# Releasing
|
# Releasing
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from pyld import jsonld
|
||||||
|
|
||||||
|
from federation.utils.django import get_redis
|
||||||
|
|
||||||
|
cache = get_redis() or {}
|
||||||
|
EXPIRATION = int(timedelta(weeks=4).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
# This is required to workaround a bug in pyld that has the Accept header
|
||||||
|
# accept other content types. From what I understand, precedence handling
|
||||||
|
# is broken
|
||||||
|
# from https://github.com/digitalbazaar/pyld/issues/133
|
||||||
|
# cacheing loosely inspired by https://github.com/digitalbazaar/pyld/issues/70
|
||||||
|
def get_loader(*args, **kwargs):
|
||||||
|
requests_loader = jsonld.requests_document_loader(*args, **kwargs)
|
||||||
|
|
||||||
|
def loader(url, options={}):
|
||||||
|
key = f'ld_cache:{url}'
|
||||||
|
try:
|
||||||
|
return json.loads(cache[key])
|
||||||
|
except KeyError:
|
||||||
|
options['headers']['Accept'] = 'application/ld+json'
|
||||||
|
doc = requests_loader(url, options)
|
||||||
|
if isinstance(cache, dict):
|
||||||
|
cache[url] = json.dumps(doc)
|
||||||
|
else:
|
||||||
|
cache.set(f'ld_cache:{url}', json.dumps(doc), ex=EXPIRATION)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
return loader
|
||||||
|
|
||||||
|
|
||||||
|
jsonld.set_document_loader(get_loader())
|
|
@ -1,22 +1,4 @@
|
||||||
CONTEXT_ACTIVITYSTREAMS = "https://www.w3.org/ns/activitystreams"
|
CONTEXT_ACTIVITYSTREAMS = "https://www.w3.org/ns/activitystreams"
|
||||||
CONTEXT_DIASPORA = {"diaspora": "https://diasporafoundation.org/ns/"}
|
CONTEXT_SECURITY = "https://w3id.org/security/v1"
|
||||||
CONTEXT_HASHTAG = {"Hashtag": "as:Hashtag"}
|
|
||||||
CONTEXT_LD_SIGNATURES = "https://w3id.org/security/v1"
|
|
||||||
CONTEXT_MANUALLY_APPROVES_FOLLOWERS = {"manuallyApprovesFollowers": "as:manuallyApprovesFollowers"}
|
|
||||||
CONTEXT_PYTHON_FEDERATION = {"pyfed": "https://docs.jasonrobinson.me/ns/python-federation#"}
|
|
||||||
CONTEXT_SENSITIVE = {"sensitive": "as:sensitive"}
|
|
||||||
|
|
||||||
CONTEXTS_DEFAULT = [
|
|
||||||
CONTEXT_ACTIVITYSTREAMS,
|
|
||||||
CONTEXT_PYTHON_FEDERATION,
|
|
||||||
]
|
|
||||||
|
|
||||||
CONTEXT = [CONTEXT_ACTIVITYSTREAMS, CONTEXT_LD_SIGNATURES]
|
|
||||||
CONTEXT_DICT = {}
|
|
||||||
for ctx in [CONTEXT_DIASPORA, CONTEXT_HASHTAG, CONTEXT_MANUALLY_APPROVES_FOLLOWERS, CONTEXT_SENSITIVE, CONTEXT_PYTHON_FEDERATION]:
|
|
||||||
CONTEXT_DICT.update(ctx)
|
|
||||||
CONTEXT_SETS = {prop: {'@id': f'as:{prop}', '@container': '@set'} for prop in ['to', 'cc', 'tag', 'attachment']}
|
|
||||||
CONTEXT_DICT.update(CONTEXT_SETS)
|
|
||||||
CONTEXT.append(CONTEXT_DICT)
|
|
||||||
|
|
||||||
NAMESPACE_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
|
NAMESPACE_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
|
|
@ -0,0 +1,151 @@
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
|
||||||
|
from marshmallow import missing
|
||||||
|
from pyld import jsonld
|
||||||
|
|
||||||
|
from federation.entities.activitypub.constants import CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY, NAMESPACE_PUBLIC
|
||||||
|
|
||||||
|
# Extract context information from the metadata parameter defined for fields
|
||||||
|
# that are not part of the official AP spec. Use the same extended context for
|
||||||
|
# inbound payload. For outbound payload, build a context with only the required
|
||||||
|
# extensions
|
||||||
|
class LdContextManager:
|
||||||
|
_named = [CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY]
|
||||||
|
_extensions = {}
|
||||||
|
_merged = []
|
||||||
|
_models = []
|
||||||
|
|
||||||
|
def __init__(self, models):
|
||||||
|
self._models = models
|
||||||
|
for klass in models:
|
||||||
|
self._extensions[klass] = {}
|
||||||
|
ctx = getattr(klass, 'ctx', [])
|
||||||
|
if ctx:
|
||||||
|
self._extensions[klass].update({klass.__name__:ctx})
|
||||||
|
for name, value in klass.schema().declared_fields.items():
|
||||||
|
ctx = value.metadata.get('ctx') or []
|
||||||
|
if ctx:
|
||||||
|
self._extensions[klass].update({name:ctx})
|
||||||
|
merged = {}
|
||||||
|
for field in self._extensions.values():
|
||||||
|
for ctx in field.values():
|
||||||
|
self._add_extensions(ctx, self._named, merged)
|
||||||
|
self._merged = copy.copy(self._named)
|
||||||
|
self._merged.append(merged)
|
||||||
|
|
||||||
|
def _add_extensions(self, field, named, extensions):
|
||||||
|
for item in field:
|
||||||
|
if isinstance(item, str) and item not in named:
|
||||||
|
named.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
extensions.update(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fields(self, obj):
|
||||||
|
for klass in self._extensions.keys():
|
||||||
|
if issubclass(type(obj), klass):
|
||||||
|
return self._extensions[klass]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def compact(self, obj):
|
||||||
|
payload = jsonld.compact(obj.dump(), self.build_context(obj))
|
||||||
|
patched = copy.copy(payload)
|
||||||
|
|
||||||
|
# This is for platforms that don't handle the single element array
|
||||||
|
# compaction to a single value and https://www.w3.org/ns/activitystreams#Public
|
||||||
|
# being compacted to as:Public
|
||||||
|
def patch_payload(payload, patched):
|
||||||
|
for field in ('attachment', 'cc', 'tag', 'to'):
|
||||||
|
value = payload.get(field)
|
||||||
|
if value and not isinstance(value, list):
|
||||||
|
value = [value]
|
||||||
|
patched[field] = value
|
||||||
|
if field in ('cc', 'to'):
|
||||||
|
try:
|
||||||
|
idx = value.index('as:Public')
|
||||||
|
patched[field][idx] = value[idx].replace('as:Public', NAMESPACE_PUBLIC)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if isinstance(payload.get('object'), dict):
|
||||||
|
patch_payload(payload['object'], patched['object'])
|
||||||
|
|
||||||
|
patch_payload(payload, patched)
|
||||||
|
return patched
|
||||||
|
|
||||||
|
def build_context(self, obj):
|
||||||
|
from federation.entities.activitypub.models import Object, Link
|
||||||
|
|
||||||
|
final = [CONTEXT_ACTIVITYSTREAMS]
|
||||||
|
extensions = {}
|
||||||
|
|
||||||
|
def walk_object(obj):
|
||||||
|
if type(obj) in self._extensions.keys():
|
||||||
|
self._add_extensions(self._extensions[type(obj)].get(type(obj).__name__, []), final, extensions)
|
||||||
|
to_add = self._get_fields(obj)
|
||||||
|
for field in type(obj).schema().declared_fields.keys():
|
||||||
|
field_value = getattr(obj, field)
|
||||||
|
if field in to_add.keys():
|
||||||
|
if field_value is not missing or obj.signable and field == 'signature':
|
||||||
|
self._add_extensions(to_add[field], final, extensions)
|
||||||
|
if not isinstance(field_value, list): field_value = [field_value]
|
||||||
|
for value in field_value:
|
||||||
|
if issubclass(type(value), (Object, Link)):
|
||||||
|
walk_object(value)
|
||||||
|
|
||||||
|
walk_object(obj)
|
||||||
|
if extensions: final.append(extensions)
|
||||||
|
# compact the array if len == 1 to minimize test changes
|
||||||
|
return final if len(final) > 1 else final[0]
|
||||||
|
|
||||||
|
def merge_context(self, ctx):
|
||||||
|
# One platform sends a single string context
|
||||||
|
if isinstance(ctx, str): ctx = [ctx]
|
||||||
|
|
||||||
|
# add a # at the end of the python-federation string
|
||||||
|
# for socialhome payloads
|
||||||
|
s = json.dumps(ctx)
|
||||||
|
if 'python-federation"' in s:
|
||||||
|
ctx = json.loads(s.replace('python-federation', 'python-federation#', 1))
|
||||||
|
|
||||||
|
# some platforms have http://joinmastodon.com/ns in @context. This
|
||||||
|
# is not a json-ld document.
|
||||||
|
try:
|
||||||
|
ctx.pop(ctx.index('http://joinmastodon.org/ns'))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# remove @language in context since this directive is not
|
||||||
|
# processed by calamus. Pleroma adds a useless @language: 'und'
|
||||||
|
# which is discouraged in best practices and in some cases makes
|
||||||
|
# calamus return dict where str is expected.
|
||||||
|
# see https://www.rfc-editor.org/rfc/rfc5646, page 56
|
||||||
|
idx = []
|
||||||
|
for i, v in enumerate(ctx):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
v.pop('@language', None)
|
||||||
|
if len(v) == 0: idx.insert(0, i)
|
||||||
|
for i in idx: ctx.pop(i)
|
||||||
|
|
||||||
|
# AP activities may be signed, but most platforms don't
|
||||||
|
# define RsaSignature2017. add it to the context
|
||||||
|
# hubzilla doesn't define the discoverable property in its context
|
||||||
|
# include all Mastodon extensions for platforms that only define http://joinmastodon.org/ns in their context
|
||||||
|
uris = []
|
||||||
|
defs = {}
|
||||||
|
# Merge original context dicts in one dict
|
||||||
|
for item in ctx:
|
||||||
|
if isinstance(item, str):
|
||||||
|
uris.append(item)
|
||||||
|
else:
|
||||||
|
defs.update(item)
|
||||||
|
|
||||||
|
for item in self._merged:
|
||||||
|
if isinstance(item, str) and item not in uris:
|
||||||
|
uris.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
defs.update(item)
|
||||||
|
|
||||||
|
final = copy.copy(uris)
|
||||||
|
final.append(defs)
|
||||||
|
return final
|
|
@ -0,0 +1,101 @@
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from base64 import b64encode, b64decode
|
||||||
|
from copy import copy
|
||||||
|
from funcy import omit
|
||||||
|
from pyld import jsonld
|
||||||
|
|
||||||
|
from Crypto.Hash import SHA256
|
||||||
|
from Crypto.PublicKey.RSA import import_key
|
||||||
|
from Crypto.Signature import pkcs1_15
|
||||||
|
|
||||||
|
from federation.utils.activitypub import retrieve_and_parse_document
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("federation")
|
||||||
|
|
||||||
|
|
||||||
|
def create_ld_signature(obj, author):
|
||||||
|
# Use models.Signature? Maybe overkill...
|
||||||
|
sig = {
|
||||||
|
'created': datetime.datetime.now(tz=datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||||
|
'creator': f'{author.id}#main-key',
|
||||||
|
'@context':'https://w3id.org/security/v1'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
private_key = import_key(author.private_key)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
logger.warning(f'ld_signature - {exc}')
|
||||||
|
return None
|
||||||
|
signer = pkcs1_15.new(private_key)
|
||||||
|
|
||||||
|
sig_digest = hash(sig)
|
||||||
|
obj_digest = hash(obj)
|
||||||
|
digest = (sig_digest + obj_digest).encode('utf-8')
|
||||||
|
|
||||||
|
signature = signer.sign(SHA256.new(digest))
|
||||||
|
sig.update({'type': 'RsaSignature2017', 'signatureValue': b64encode(signature).decode()})
|
||||||
|
sig.pop('@context')
|
||||||
|
|
||||||
|
obj.update({'signature':sig})
|
||||||
|
|
||||||
|
|
||||||
|
def verify_ld_signature(payload):
|
||||||
|
"""
|
||||||
|
Verify inbound payload LD signature
|
||||||
|
"""
|
||||||
|
signature = copy(payload.get('signature', None))
|
||||||
|
if not signature:
|
||||||
|
logger.warning(f'ld_signature - No signature in {payload.get("id", "the payload")}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# retrieve the author's public key
|
||||||
|
profile = retrieve_and_parse_document(signature.get('creator'))
|
||||||
|
if not profile:
|
||||||
|
logger.warning(f'ld_signature - Failed to retrieve profile for {signature.get("creator")}')
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pkey = import_key(profile.public_key)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(f'ld_signature - {exc}')
|
||||||
|
return None
|
||||||
|
verifier = pkcs1_15.new(pkey)
|
||||||
|
|
||||||
|
# Compute digests and verify signature
|
||||||
|
sig = omit(signature, ('type', 'signatureValue'))
|
||||||
|
sig.update({'@context':'https://w3id.org/security/v1'})
|
||||||
|
sig_digest = hash(sig)
|
||||||
|
obj = omit(payload, 'signature')
|
||||||
|
obj_digest = hash(obj)
|
||||||
|
digest = (sig_digest + obj_digest).encode('utf-8')
|
||||||
|
|
||||||
|
sig_value = b64decode(signature.get('signatureValue'))
|
||||||
|
try:
|
||||||
|
verifier.verify(SHA256.new(digest), sig_value)
|
||||||
|
logger.debug(f'ld_signature - {payload.get("id")} has a valid signature')
|
||||||
|
return profile.id
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f'ld_signature - Invalid signature for {payload.get("id")}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hash(obj):
|
||||||
|
nquads = NormalizedDoubles().normalize(obj, options={'format':'application/nquads','algorithm':'URDNA2015'})
|
||||||
|
return SHA256.new(nquads.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# We need this to ensure the digests are identical.
|
||||||
|
class NormalizedDoubles(jsonld.JsonLdProcessor):
|
||||||
|
def _object_to_rdf(self, item, issuer, triples, rdfDirection):
|
||||||
|
value = item['@value'] if jsonld._is_value(item) else None
|
||||||
|
# The ruby rdf_normalize library turns floats with a zero fraction into integers.
|
||||||
|
if isinstance(value, float) and value == math.floor(value):
|
||||||
|
item['@value'] = math.floor(value)
|
||||||
|
obj = super()._object_to_rdf(item, issuer, triples, rdfDirection)
|
||||||
|
# This is to address https://github.com/digitalbazaar/pyld/issues/175
|
||||||
|
if obj.get('datatype') == jsonld.XSD_DOUBLE:
|
||||||
|
obj['value'] = re.sub(r'(\d)0*E\+?(-)?0*(\d)', r'\1E\2\3', obj['value'])
|
||||||
|
return obj
|
|
@ -84,6 +84,6 @@ def message_to_objects(
|
||||||
Takes in a message extracted by a protocol and maps it to entities.
|
Takes in a message extracted by a protocol and maps it to entities.
|
||||||
"""
|
"""
|
||||||
# We only really expect one element here for ActivityPub.
|
# We only really expect one element here for ActivityPub.
|
||||||
return element_to_objects(message)
|
return element_to_objects(message, sender)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from copy import copy
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import List, Dict, Union
|
from typing import List, Dict, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
@ -16,8 +16,9 @@ from marshmallow.utils import EXCLUDE, missing
|
||||||
from pyld import jsonld
|
from pyld import jsonld
|
||||||
|
|
||||||
import federation.entities.base as base
|
import federation.entities.base as base
|
||||||
import federation.utils.jsonld_helper
|
from federation.entities.activitypub.constants import CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY, NAMESPACE_PUBLIC
|
||||||
from federation.entities.activitypub.constants import CONTEXT, CONTEXT_SETS, NAMESPACE_PUBLIC
|
from federation.entities.activitypub.ldcontext import LdContextManager
|
||||||
|
from federation.entities.activitypub.ldsigning import create_ld_signature, verify_ld_signature
|
||||||
from federation.entities.mixins import BaseEntity, RawContentMixin
|
from federation.entities.mixins import BaseEntity, RawContentMixin
|
||||||
from federation.entities.utils import get_base_attributes, get_profile
|
from federation.entities.utils import get_base_attributes, get_profile
|
||||||
from federation.outbound import handle_send
|
from federation.outbound import handle_send
|
||||||
|
@ -108,7 +109,7 @@ class NormalizedList(fields.List):
|
||||||
|
|
||||||
# Don't want expanded IRIs to be exposed as dict keys
|
# Don't want expanded IRIs to be exposed as dict keys
|
||||||
class CompactedDict(fields.Dict):
|
class CompactedDict(fields.Dict):
|
||||||
ctx = ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"]
|
ctx = [CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY]
|
||||||
|
|
||||||
# may or may not be needed
|
# may or may not be needed
|
||||||
def _serialize(self, value, attr, obj, **kwargs):
|
def _serialize(self, value, attr, obj, **kwargs):
|
||||||
|
@ -133,12 +134,12 @@ class CompactedDict(fields.Dict):
|
||||||
# calamus sets a XMLSchema#integer type, but different definitions
|
# calamus sets a XMLSchema#integer type, but different definitions
|
||||||
# maybe used, hence the flavor property
|
# maybe used, hence the flavor property
|
||||||
# TODO: handle non negative types
|
# TODO: handle non negative types
|
||||||
class Integer(fields._JsonLDField, Integer):
|
class MixedInteger(fields._JsonLDField, Integer):
|
||||||
flavor = None # add fields.IRIReference type hint
|
flavor = None # add fields.IRIReference type hint
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.flavor = kwargs.get('flavor')
|
self.flavor = self.metadata.get('flavor')
|
||||||
|
|
||||||
def _serialize(self, value, attr, obj, **kwargs):
|
def _serialize(self, value, attr, obj, **kwargs):
|
||||||
value = super()._serialize(value, attr, obj, **kwargs)
|
value = super()._serialize(value, attr, obj, **kwargs)
|
||||||
|
@ -227,7 +228,6 @@ OBJECTS = [
|
||||||
'VideoSchema'
|
'VideoSchema'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def set_public(entity):
|
def set_public(entity):
|
||||||
for attr in [entity.to, entity.cc]:
|
for attr in [entity.to, entity.cc]:
|
||||||
if isinstance(attr, list):
|
if isinstance(attr, list):
|
||||||
|
@ -235,38 +235,31 @@ def set_public(entity):
|
||||||
elif attr == NAMESPACE_PUBLIC: entity.public = True
|
elif attr == NAMESPACE_PUBLIC: entity.public = True
|
||||||
|
|
||||||
|
|
||||||
def add_props_to_attrs(obj, props):
|
|
||||||
return obj.__dict__
|
|
||||||
attrs = copy(obj.__dict__)
|
|
||||||
for prop in props:
|
|
||||||
attrs.update({prop: getattr(obj, prop, None)})
|
|
||||||
attrs.pop('_'+prop, None)
|
|
||||||
attrs.update({'schema': True})
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class Object(BaseEntity, metaclass=JsonLDAnnotation):
|
class Object(BaseEntity, metaclass=JsonLDAnnotation):
|
||||||
atom_url = fields.String(ostatus.atomUri)
|
atom_url = fields.String(ostatus.atomUri)
|
||||||
also_known_as = IRI(as2.alsoKnownAs)
|
also_known_as = IRI(as2.alsoKnownAs,
|
||||||
|
metadata={'ctx':[{ 'alsoKnownAs':{'@id':'as:alsoKnownAs','@type':'@id'}}]})
|
||||||
icon = MixedField(as2.icon, nested='ImageSchema')
|
icon = MixedField(as2.icon, nested='ImageSchema')
|
||||||
image = MixedField(as2.image, nested='ImageSchema', default='')
|
image = MixedField(as2.image, nested='ImageSchema')
|
||||||
tag_objects = MixedField(as2.tag, nested=['HashtagSchema','MentionSchema','PropertyValueSchema','EmojiSchema'], many=True)
|
tag_objects = MixedField(as2.tag, nested=['HashtagSchema','MentionSchema','PropertyValueSchema','EmojiSchema'], many=True)
|
||||||
attachment = fields.Nested(as2.attachment, nested=['ImageSchema', 'AudioSchema', 'DocumentSchema','PropertyValueSchema','IdentityProofSchema'], many=True)
|
attachment = fields.Nested(as2.attachment, nested=['ImageSchema', 'AudioSchema', 'DocumentSchema','PropertyValueSchema','IdentityProofSchema'],
|
||||||
|
many=True, default=[])
|
||||||
content_map = LanguageMap(as2.content) # language maps are not implemented in calamus
|
content_map = LanguageMap(as2.content) # language maps are not implemented in calamus
|
||||||
context = IRI(as2.context)
|
context = fields.RawJsonLD(as2.context)
|
||||||
guid = fields.String(diaspora.guid, default='')
|
|
||||||
handle = fields.String(diaspora.handle, default='')
|
|
||||||
name = fields.String(as2.name, default='')
|
name = fields.String(as2.name, default='')
|
||||||
generator = MixedField(as2.generator, nested=['ApplicationSchema','ServiceSchema'])
|
generator = MixedField(as2.generator, nested=['ApplicationSchema','ServiceSchema'])
|
||||||
created_at = fields.DateTime(as2.published, add_value_types=True)
|
created_at = fields.DateTime(as2.published, add_value_types=True)
|
||||||
replies = MixedField(as2.replies, nested=['CollectionSchema','OrderedCollectionSchema'])
|
replies = MixedField(as2.replies, nested=['CollectionSchema','OrderedCollectionSchema'])
|
||||||
signature = MixedField(sec.signature, nested = 'SignatureSchema')
|
signature = MixedField(sec.signature, nested = 'SignatureSchema',
|
||||||
|
metadata={'ctx': [CONTEXT_SECURITY,
|
||||||
|
{'RsaSignature2017':'sec:RsaSignature2017'}]})
|
||||||
start_time = fields.DateTime(as2.startTime, add_value_types=True)
|
start_time = fields.DateTime(as2.startTime, add_value_types=True)
|
||||||
updated = fields.DateTime(as2.updated, add_value_types=True)
|
updated = fields.DateTime(as2.updated, add_value_types=True)
|
||||||
to = fields.List(as2.to, cls_or_instance=fields.String(as2.to))
|
to = fields.List(as2.to, cls_or_instance=IRI(as2.to), default=[])
|
||||||
cc = fields.List(as2.cc, cls_or_instance=fields.String(as2.cc))
|
cc = fields.List(as2.cc, cls_or_instance=IRI(as2.cc), default=[])
|
||||||
media_type = fields.String(as2.mediaType)
|
media_type = fields.String(as2.mediaType)
|
||||||
source = CompactedDict(as2.source)
|
source = CompactedDict(as2.source)
|
||||||
|
signable = False
|
||||||
|
|
||||||
# The following properties are defined by some platforms, but are not implemented yet
|
# The following properties are defined by some platforms, but are not implemented yet
|
||||||
#audience
|
#audience
|
||||||
|
@ -279,7 +272,12 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation):
|
||||||
|
|
||||||
def to_as2(self):
|
def to_as2(self):
|
||||||
obj = self.activity if isinstance(self.activity, Activity) else self
|
obj = self.activity if isinstance(self.activity, Activity) else self
|
||||||
return jsonld.compact(obj.dump(), CONTEXT)
|
return context_manager.compact(obj)
|
||||||
|
|
||||||
|
def sign_as2(self, sender=None):
|
||||||
|
obj = self.to_as2()
|
||||||
|
if self.signable and sender: create_ld_signature(obj, sender)
|
||||||
|
return obj
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_base(cls, entity):
|
def from_base(cls, entity):
|
||||||
|
@ -315,86 +313,8 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation):
|
||||||
@pre_load
|
@pre_load
|
||||||
def patch_context(self, data, **kwargs):
|
def patch_context(self, data, **kwargs):
|
||||||
if not data.get('@context'): return data
|
if not data.get('@context'): return data
|
||||||
ctx = copy(data['@context'])
|
ctx = copy.copy(data['@context'])
|
||||||
|
data['@context'] = context_manager.merge_context(ctx)
|
||||||
# One platform send a single string context
|
|
||||||
if isinstance(ctx, str): ctx = [ctx]
|
|
||||||
|
|
||||||
# add a # at the end of the python-federation string
|
|
||||||
# for socialhome payloads
|
|
||||||
s = json.dumps(ctx)
|
|
||||||
if 'python-federation"' in s:
|
|
||||||
ctx = json.loads(s.replace('python-federation', 'python-federation#', 1))
|
|
||||||
|
|
||||||
# some paltforms have http://joinmastodon.com/ns in @context. This
|
|
||||||
# is not a json-ld document.
|
|
||||||
try:
|
|
||||||
ctx.pop(ctx.index('http://joinmastodon.org/ns'))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# remove @language in context since this directive is not
|
|
||||||
# processed by calamus. Pleroma adds a useless @language: 'und'
|
|
||||||
# which is discouraged in best practices and in some cases makes
|
|
||||||
# calamus return dict where str is expected.
|
|
||||||
# see https://www.rfc-editor.org/rfc/rfc5646, page 56
|
|
||||||
idx = []
|
|
||||||
for i,v in enumerate(ctx):
|
|
||||||
if isinstance(v, dict):
|
|
||||||
v.pop('@language',None)
|
|
||||||
if len(v) == 0: idx.insert(0, i)
|
|
||||||
for i in idx: ctx.pop(i)
|
|
||||||
|
|
||||||
# AP activities may be signed, but most platforms don't
|
|
||||||
# define RsaSignature2017. add it to the context
|
|
||||||
# hubzilla doesn't define the discoverable property in its context
|
|
||||||
# include all Mastodon extensions for platforms that only define http://joinmastodon.org/ns in their context
|
|
||||||
may_add = {'signature': ['https://w3id.org/security/v1', {'sec':'https://w3id.org/security#','RsaSignature2017':'sec:RsaSignature2017'}],
|
|
||||||
'publicKey': ['https://w3id.org/security/v1'],
|
|
||||||
'discoverable': [{'toot':'http://joinmastodon.org/ns#','discoverable': 'toot:discoverable'}], #for hubzilla
|
|
||||||
'suspended': [{'toot':'http://joinmastodon.org/ns#','suspended': 'toot:suspended'}],
|
|
||||||
'copiedTo': [{'toot':'http://joinmastodon.org/ns#','copiedTo': 'toot:copiedTo'}], #for hubzilla
|
|
||||||
'featured': [{'toot':'http://joinmastodon.org/ns#','featured': 'toot:featured'}], #for litepub and pleroma
|
|
||||||
'featuredTags': [{'toot':'http://joinmastodon.org/ns#','featuredTags': 'toot:featuredTags'}],
|
|
||||||
'focalPoint': [{'toot':'http://joinmastodon.org/ns#',
|
|
||||||
'focalPoint': {'@id':'toot:focalPoint','@container':'@list'},
|
|
||||||
}],
|
|
||||||
'tag': [{'Hashtag': 'as:Hashtag', #for epicyon
|
|
||||||
'toot':'http://joinmastodon.org/ns#',
|
|
||||||
'Emoji':'toot:Emoji'}],
|
|
||||||
'attachment': [{'schema': 'http://schema.org#', 'PropertyValue': 'schema:PropertyValue', # for owncast
|
|
||||||
'toot':'http://joinmastodon.org/ns#','blurHash': 'toot:blurHash',
|
|
||||||
'IdentityProof': 'toot:IdentityProof'}]
|
|
||||||
}
|
|
||||||
|
|
||||||
to_add = [val for key,val in may_add.items() if data.get(key)]
|
|
||||||
if to_add:
|
|
||||||
idx = [i for i,v in enumerate(ctx) if isinstance(v, dict)]
|
|
||||||
if idx:
|
|
||||||
upd = ctx[idx[0]]
|
|
||||||
# merge context dicts
|
|
||||||
if len(idx) > 1:
|
|
||||||
idx.reverse()
|
|
||||||
for i in idx[:-1]:
|
|
||||||
upd.update(ctx[i])
|
|
||||||
ctx.pop(i)
|
|
||||||
else:
|
|
||||||
upd = {}
|
|
||||||
|
|
||||||
for add in to_add:
|
|
||||||
for val in add:
|
|
||||||
if isinstance(val, str) and val not in ctx:
|
|
||||||
try:
|
|
||||||
ctx.append(val)
|
|
||||||
except AttributeError:
|
|
||||||
ctx = [ctx, val]
|
|
||||||
if isinstance(val, dict):
|
|
||||||
upd.update(val)
|
|
||||||
if not idx and upd: ctx.append(upd)
|
|
||||||
|
|
||||||
# for to and cc fields to be processed as strings
|
|
||||||
ctx.append(CONTEXT_SETS)
|
|
||||||
data['@context'] = ctx
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# A node without an id isn't true json-ld, but many payloads have
|
# A node without an id isn't true json-ld, but many payloads have
|
||||||
|
@ -424,7 +344,7 @@ class Collection(Object, base.Collection):
|
||||||
first = MixedField(as2.first, nested=['CollectionPageSchema', 'OrderedCollectionPageSchema'])
|
first = MixedField(as2.first, nested=['CollectionPageSchema', 'OrderedCollectionPageSchema'])
|
||||||
current = IRI(as2.current)
|
current = IRI(as2.current)
|
||||||
last = IRI(as2.last)
|
last = IRI(as2.last)
|
||||||
total_items = Integer(as2.totalItems, flavor=xsd.nonNegativeInteger, add_value_types=True)
|
total_items = MixedInteger(as2.totalItems, metafdata={'flavor':xsd.nonNegativeInteger}, add_value_types=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.Collection
|
rdf_type = as2.Collection
|
||||||
|
@ -447,7 +367,7 @@ class CollectionPage(Collection):
|
||||||
|
|
||||||
|
|
||||||
class OrderedCollectionPage(OrderedCollection, CollectionPage):
|
class OrderedCollectionPage(OrderedCollection, CollectionPage):
|
||||||
start_index = Integer(as2.startIndex, flavor=xsd.nonNegativeInteger, add_value_types=True)
|
start_index = MixedInteger(as2.startIndex, metadata={'flavor':xsd.nonNegativeInteger}, add_value_types=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.OrderedCollectionPage
|
rdf_type = as2.OrderedCollectionPage
|
||||||
|
@ -457,10 +377,12 @@ class OrderedCollectionPage(OrderedCollection, CollectionPage):
|
||||||
# AP defines [Ii]mage and [Aa]udio objects/properties, but only a Video object
|
# AP defines [Ii]mage and [Aa]udio objects/properties, but only a Video object
|
||||||
# seen with Peertube payloads only so far
|
# seen with Peertube payloads only so far
|
||||||
class Document(Object):
|
class Document(Object):
|
||||||
inline = fields.Boolean(pyfed.inlineImage, default=False)
|
inline = fields.Boolean(pyfed.inlineImage, default=False,
|
||||||
height = Integer(as2.height, default=0, flavor=xsd.nonNegativeInteger, add_value_types=True)
|
metadata={'ctx':[{'pyfed':str(pyfed)}]})
|
||||||
width = Integer(as2.width, default=0, flavor=xsd.nonNegativeInteger, add_value_types=True)
|
height = MixedInteger(as2.height, default=0, metadata={'flavor':xsd.nonNegativeInteger}, add_value_types=True)
|
||||||
blurhash = fields.String(toot.blurhash)
|
width = MixedInteger(as2.width, default=0, metadata={'flavor':xsd.nonNegativeInteger}, add_value_types=True)
|
||||||
|
blurhash = fields.String(toot.blurHash,
|
||||||
|
metadata={'ctx':[{'toot':str(toot),'blurHash':'toot:blurHash'}]})
|
||||||
url = MixedField(as2.url, nested='LinkSchema')
|
url = MixedField(as2.url, nested='LinkSchema')
|
||||||
|
|
||||||
def to_base(self):
|
def to_base(self):
|
||||||
|
@ -507,10 +429,10 @@ class Link(metaclass=JsonLDAnnotation):
|
||||||
media_type = fields.String(as2.mediaType)
|
media_type = fields.String(as2.mediaType)
|
||||||
name = fields.String(as2.name)
|
name = fields.String(as2.name)
|
||||||
href_lang = fields.String(as2.hrefLang)
|
href_lang = fields.String(as2.hrefLang)
|
||||||
height = Integer(as2.height, flavor=xsd.nonNegativeInteger, add_value_types=True)
|
height = MixedInteger(as2.height, metadata={'flavor':xsd.nonNegativeInteger}, add_value_types=True)
|
||||||
width = Integer(as2.width, flavor=xsd.nonNegativeInteger, add_value_types=True)
|
width = MixedInteger(as2.width, metadata={'flavor':xsd.nonNegativeInteger}, add_value_types=True)
|
||||||
fps = Integer(pt.fps, flavor=schema.Number, add_value_types=True)
|
fps = MixedInteger(pt.fps, metadata={'flavor':schema.Number}, add_value_types=True)
|
||||||
size = Integer(pt.size, flavor=schema.Number, add_value_types=True)
|
size = MixedInteger(pt.size, metadata={'flavor':schema.Number}, add_value_types=True)
|
||||||
tag = MixedField(as2.tag, nested=['InfohashSchema', 'LinkSchema'], many=True)
|
tag = MixedField(as2.tag, nested=['InfohashSchema', 'LinkSchema'], many=True)
|
||||||
# Not implemented yet
|
# Not implemented yet
|
||||||
#preview : variable type?
|
#preview : variable type?
|
||||||
|
@ -534,6 +456,7 @@ class Link(metaclass=JsonLDAnnotation):
|
||||||
|
|
||||||
|
|
||||||
class Hashtag(Link):
|
class Hashtag(Link):
|
||||||
|
ctx = [{'Hashtag': 'as:Hashtag'}]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.Hashtag
|
rdf_type = as2.Hashtag
|
||||||
|
@ -546,7 +469,9 @@ class Mention(Link):
|
||||||
|
|
||||||
|
|
||||||
class PropertyValue(Object):
|
class PropertyValue(Object):
|
||||||
value = fields.String(schema.value)
|
value = fields.String(schema.value,
|
||||||
|
metadata={'ctx':[{'schema':str(schema),'value':'schema:value'}]})
|
||||||
|
ctx = [{'schema':str(schema),'PropertyValue':'schema:PropertyValue'}]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = schema.PropertyValue
|
rdf_type = schema.PropertyValue
|
||||||
|
@ -555,12 +480,14 @@ class PropertyValue(Object):
|
||||||
class IdentityProof(Object):
|
class IdentityProof(Object):
|
||||||
signature_value = fields.String(sec.signatureValue)
|
signature_value = fields.String(sec.signatureValue)
|
||||||
signing_algorithm = fields.String(sec.signingAlgorithm)
|
signing_algorithm = fields.String(sec.signingAlgorithm)
|
||||||
|
ctx = [CONTEXT_SECURITY]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = toot.IdentityProof
|
rdf_type = toot.IdentityProof
|
||||||
|
|
||||||
|
|
||||||
class Emoji(Object):
|
class Emoji(Object):
|
||||||
|
ctx = [{'toot':'http://joinmastodon.org/ns#','Emoji':'toot:Emoji'}]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = toot.Emoji
|
rdf_type = toot.Emoji
|
||||||
|
@ -572,26 +499,40 @@ class Person(Object, base.Profile):
|
||||||
outbox = IRI(as2.outbox)
|
outbox = IRI(as2.outbox)
|
||||||
following = IRI(as2.following)
|
following = IRI(as2.following)
|
||||||
followers = IRI(as2.followers)
|
followers = IRI(as2.followers)
|
||||||
|
guid = fields.String(diaspora.guid, metadata={'ctx':[{'diaspora':str(diaspora)}]})
|
||||||
|
handle = fields.String(diaspora.handle, metadata={'ctx':[{'diaspora':str(diaspora)}]})
|
||||||
username = fields.String(as2.preferredUsername)
|
username = fields.String(as2.preferredUsername)
|
||||||
endpoints = CompactedDict(as2.endpoints)
|
endpoints = CompactedDict(as2.endpoints)
|
||||||
shared_inbox = IRI(as2.sharedInbox) # misskey adds this
|
shared_inbox = IRI(as2.sharedInbox) # misskey adds this
|
||||||
url = MixedField(as2.url, nested='LinkSchema')
|
url = MixedField(as2.url, nested='LinkSchema')
|
||||||
playlists = IRI(pt.playlists)
|
playlists = IRI(pt.playlists)
|
||||||
featured = IRI(toot.featured)
|
featured = IRI(toot.featured,
|
||||||
featuredTags = IRI(toot.featuredTags)
|
metadata={'ctx':[{'toot':str(toot),
|
||||||
manuallyApprovesFollowers = fields.Boolean(as2.manuallyApprovesFollowers, default=False)
|
'featured': {'@id':'toot:featured','@type':'@id'}}]})
|
||||||
discoverable = fields.Boolean(toot.discoverable)
|
featured_tags = IRI(toot.featuredTags,
|
||||||
|
metadata={'ctx':[{'toot':str(toot),
|
||||||
|
'featuredTags': {'@id':'toot:featuredTags','@type':'@id'}}]})
|
||||||
|
manually_approves_followers = fields.Boolean(as2.manuallyApprovesFollowers, default=False,
|
||||||
|
metadata={'ctx':[{'manuallyApprovesFollowers':'as:manuallyApprovesFollowers'}]})
|
||||||
|
discoverable = fields.Boolean(toot.discoverable,
|
||||||
|
metadata={'ctx':[{'toot':str(toot),
|
||||||
|
'discoverable': 'toot:discoverable'}]})
|
||||||
devices = IRI(toot.devices)
|
devices = IRI(toot.devices)
|
||||||
public_key_dict = CompactedDict(sec.publicKey)
|
public_key_dict = CompactedDict(sec.publicKey,
|
||||||
raw_content = fields.String(as2.summary, default="")
|
metadata={'ctx':[CONTEXT_SECURITY]})
|
||||||
|
raw_content = fields.String(as2.summary, default='')
|
||||||
has_address = MixedField(vcard.hasAddress, nested='HomeSchema')
|
has_address = MixedField(vcard.hasAddress, nested='HomeSchema')
|
||||||
has_instant_message = fields.List(vcard.hasInstantMessage, cls_or_instance=fields.String)
|
has_instant_message = fields.List(vcard.hasInstantMessage, cls_or_instance=fields.String)
|
||||||
address = fields.String(vcard.Address)
|
address = fields.String(vcard.Address)
|
||||||
is_cat = fields.Boolean(misskey.isCat)
|
is_cat = fields.Boolean(misskey.isCat)
|
||||||
moved_to = IRI(as2.movedTo)
|
moved_to = IRI(as2.movedTo,
|
||||||
copied_to = IRI(toot.copiedTo)
|
metadata={'ctx':[{'movedTo':{'@id':'as:movedTo','@type':'@id'}}]})
|
||||||
|
copied_to = IRI(as2.copiedTo,
|
||||||
|
metadata={'ctx':[{'copiedTo':{'@id':'as:copiedTo','@type':'@id'}}]})
|
||||||
capabilities = CompactedDict(litepub.capabilities)
|
capabilities = CompactedDict(litepub.capabilities)
|
||||||
suspended = fields.Boolean(toot.suspended)
|
suspended = fields.Boolean(toot.suspended,
|
||||||
|
metadata={'ctx':[{'toot':str(toot),
|
||||||
|
'suspended': 'toot:suspended'}]})
|
||||||
public = True
|
public = True
|
||||||
_cached_inboxes = None
|
_cached_inboxes = None
|
||||||
_cached_public_key = None
|
_cached_public_key = None
|
||||||
|
@ -622,7 +563,7 @@ class Person(Object, base.Profile):
|
||||||
if get_profile_id_from_webfinger(finger) == self.id:
|
if get_profile_id_from_webfinger(finger) == self.id:
|
||||||
self.finger = finger
|
self.finger = finger
|
||||||
# multi-protocol platform
|
# multi-protocol platform
|
||||||
if self.finger and self.guid and not self.handle:
|
if self.finger and self.guid is not missing and self.handle is missing:
|
||||||
self.handle = self.finger
|
self.handle = self.finger
|
||||||
|
|
||||||
def to_as2(self):
|
def to_as2(self):
|
||||||
|
@ -678,10 +619,11 @@ class Person(Object, base.Profile):
|
||||||
def image_urls(self):
|
def image_urls(self):
|
||||||
if getattr(self, 'icon', None):
|
if getattr(self, 'icon', None):
|
||||||
icon = self.icon if not isinstance(self.icon, list) else self.icon[0]
|
icon = self.icon if not isinstance(self.icon, list) else self.icon[0]
|
||||||
|
url = icon if isinstance(icon, str) else icon.url
|
||||||
self._cached_image_urls = {
|
self._cached_image_urls = {
|
||||||
'small': icon.url,
|
'small': url,
|
||||||
'medium': icon.url,
|
'medium': url,
|
||||||
'large': icon.url
|
'large': url
|
||||||
}
|
}
|
||||||
return self._cached_image_urls
|
return self._cached_image_urls
|
||||||
|
|
||||||
|
@ -737,14 +679,22 @@ class Note(Object, RawContentMixin):
|
||||||
id = fields.Id()
|
id = fields.Id()
|
||||||
actor_id = IRI(as2.attributedTo)
|
actor_id = IRI(as2.attributedTo)
|
||||||
target_id = IRI(as2.inReplyTo, default=None)
|
target_id = IRI(as2.inReplyTo, default=None)
|
||||||
conversation = fields.RawJsonLD(ostatus.conversation)
|
conversation = fields.RawJsonLD(ostatus.conversation,
|
||||||
|
metadata={'ctx':[{'ostatus':str(ostatus),
|
||||||
|
'conversation':'ostatus:conversation'}]})
|
||||||
entity_type = 'Post'
|
entity_type = 'Post'
|
||||||
in_reply_to_atom_uri = IRI(ostatus.inReplyToAtomUri)
|
guid = fields.String(diaspora.guid, metadata={'ctx':[{'diaspora':str(diaspora)}]})
|
||||||
sensitive = fields.Boolean(as2.sensitive, default=False)
|
in_reply_to_atom_uri = IRI(ostatus.inReplyToAtomUri,
|
||||||
|
metadata={'ctx':[{'ostatus':str(ostatus),
|
||||||
|
'inReplyToAtomUri':'ostatus:inReplyToAtomUri'}]})
|
||||||
|
sensitive = fields.Boolean(as2.sensitive, default=False,
|
||||||
|
metadata={'ctx':[{'sensitive':'as:sensitive'}]})
|
||||||
summary = fields.String(as2.summary)
|
summary = fields.String(as2.summary)
|
||||||
url = IRI(as2.url)
|
url = IRI(as2.url)
|
||||||
|
|
||||||
_cached_raw_content = ''
|
_cached_raw_content = ''
|
||||||
_cached_children = []
|
_cached_children = []
|
||||||
|
signable = True
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.tag_objects = [] # mutable objects...
|
self.tag_objects = [] # mutable objects...
|
||||||
|
@ -752,7 +702,8 @@ class Note(Object, RawContentMixin):
|
||||||
self._allowed_children += (base.Audio, base.Video)
|
self._allowed_children += (base.Audio, base.Video)
|
||||||
|
|
||||||
def to_as2(self):
|
def to_as2(self):
|
||||||
self.sensitive = 'nsfw' in self.tags
|
#self.sensitive = 'nsfw' in self.tags
|
||||||
|
self.url = self.id
|
||||||
|
|
||||||
edited = False
|
edited = False
|
||||||
if hasattr(self, 'times'):
|
if hasattr(self, 'times'):
|
||||||
|
@ -779,7 +730,8 @@ class Note(Object, RawContentMixin):
|
||||||
|
|
||||||
def to_base(self):
|
def to_base(self):
|
||||||
kwargs = get_base_attributes(self, keep=(
|
kwargs = get_base_attributes(self, keep=(
|
||||||
'_mentions', '_media_type', '_rendered_content', '_cached_children', '_cached_raw_content'))
|
'_mentions', '_media_type', '_rendered_content', '_source_object',
|
||||||
|
'_cached_children', '_cached_raw_content'))
|
||||||
entity = Comment(**kwargs) if getattr(self, 'target_id') else Post(**kwargs)
|
entity = Comment(**kwargs) if getattr(self, 'target_id') else Post(**kwargs)
|
||||||
# Plume (and maybe other platforms) send the attrbutedTo field as an array
|
# Plume (and maybe other platforms) send the attrbutedTo field as an array
|
||||||
if isinstance(entity.actor_id, list): entity.actor_id = entity.actor_id[0]
|
if isinstance(entity.actor_id, list): entity.actor_id = entity.actor_id[0]
|
||||||
|
@ -958,13 +910,11 @@ class Note(Object, RawContentMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.Note
|
rdf_type = as2.Note
|
||||||
exclude = ('handle',)
|
|
||||||
|
|
||||||
|
|
||||||
class Post(Note, base.Post):
|
class Post(Note, base.Post):
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.Note
|
rdf_type = as2.Note
|
||||||
exclude = ('handle',)
|
|
||||||
|
|
||||||
|
|
||||||
class Comment(Note, base.Comment):
|
class Comment(Note, base.Comment):
|
||||||
|
@ -978,7 +928,6 @@ class Comment(Note, base.Comment):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.Note
|
rdf_type = as2.Note
|
||||||
exclude = ('handle',)
|
|
||||||
|
|
||||||
|
|
||||||
class Article(Note):
|
class Article(Note):
|
||||||
|
@ -996,6 +945,7 @@ class Video(Document, base.Video):
|
||||||
id = fields.Id()
|
id = fields.Id()
|
||||||
actor_id = MixedField(as2.attributedTo, nested=['PersonSchema', 'GroupSchema'], many=True)
|
actor_id = MixedField(as2.attributedTo, nested=['PersonSchema', 'GroupSchema'], many=True)
|
||||||
url = MixedField(as2.url, nested='LinkSchema')
|
url = MixedField(as2.url, nested='LinkSchema')
|
||||||
|
signable = True
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unknown = EXCLUDE # required until all the pt fields are defined
|
unknown = EXCLUDE # required until all the pt fields are defined
|
||||||
|
@ -1031,7 +981,8 @@ class Video(Document, base.Video):
|
||||||
self.actor_id = new_act[0]
|
self.actor_id = new_act[0]
|
||||||
|
|
||||||
entity = Post(**get_base_attributes(self,
|
entity = Post(**get_base_attributes(self,
|
||||||
keep=('_mentions', '_media_type', '_rendered_content', '_cached_children', '_cached_raw_content')))
|
keep=('_mentions', '_media_type', '_rendered_content',
|
||||||
|
'_cached_children', '_cached_raw_content', '_source_object')))
|
||||||
set_public(entity)
|
set_public(entity)
|
||||||
return entity
|
return entity
|
||||||
#Some Video object
|
#Some Video object
|
||||||
|
@ -1141,7 +1092,7 @@ class Follow(Activity, base.Follow):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.Follow
|
rdf_type = as2.Follow
|
||||||
exclude = ('created_at', 'handle')
|
exclude = ('created_at',)
|
||||||
|
|
||||||
|
|
||||||
class Announce(Activity, base.Share):
|
class Announce(Activity, base.Share):
|
||||||
|
@ -1176,14 +1127,20 @@ class Announce(Activity, base.Share):
|
||||||
self.target_id = self.id
|
self.target_id = self.id
|
||||||
self.entity_type = 'Object'
|
self.entity_type = 'Object'
|
||||||
self.__dict__.update({'schema': True})
|
self.__dict__.update({'schema': True})
|
||||||
entity = base.Retraction(**get_base_attributes(self))
|
entity = Retraction(**get_base_attributes(self, keep=('_source_object',)))
|
||||||
|
|
||||||
set_public(entity)
|
set_public(entity)
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
rdf_type = as2.Announce
|
rdf_type = as2.Announce
|
||||||
|
|
||||||
|
|
||||||
|
# Only used for inbound share retraction (undo announce)
|
||||||
|
class Retraction(Announce, base.Retraction):
|
||||||
|
class Meta:
|
||||||
|
rdf_type = as2.Announce
|
||||||
|
|
||||||
|
|
||||||
class Tombstone(Object, base.Retraction):
|
class Tombstone(Object, base.Retraction):
|
||||||
target_id = fields.Id()
|
target_id = fields.Id()
|
||||||
|
@ -1354,7 +1311,7 @@ def extract_replies(replies):
|
||||||
return objs
|
return objs
|
||||||
|
|
||||||
|
|
||||||
def element_to_objects(element: Union[Dict, Object]) -> List:
|
def element_to_objects(element: Union[Dict, Object], sender: str = "") -> List:
|
||||||
"""
|
"""
|
||||||
Transform an Element to a list of entities.
|
Transform an Element to a list of entities.
|
||||||
"""
|
"""
|
||||||
|
@ -1372,7 +1329,14 @@ def element_to_objects(element: Union[Dict, Object]) -> List:
|
||||||
extract_and_validate(entity)
|
extract_and_validate(entity)
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
logger.error("Failed to validate entity %s: %s", entity, ex)
|
logger.error("Failed to validate entity %s: %s", entity, ex)
|
||||||
return None
|
return []
|
||||||
|
# Always verify the LD signature, for monitoring purposes
|
||||||
|
actor = verify_ld_signature(entity._source_object)
|
||||||
|
if entity.signable and sender not in (entity.id, getattr(entity, 'actor_id', None), ''):
|
||||||
|
# Relayed payload
|
||||||
|
if not actor:
|
||||||
|
logger.warning(f'no or invalid signature for a relayed payload, fetching {entity.id}')
|
||||||
|
entity = retrieve_and_parse_document(entity.id)
|
||||||
logger.info('Entity type "%s" was handled through the json-ld processor', entity.__class__.__name__)
|
logger.info('Entity type "%s" was handled through the json-ld processor', entity.__class__.__name__)
|
||||||
return [entity]
|
return [entity]
|
||||||
elif entity:
|
elif entity:
|
||||||
|
@ -1385,11 +1349,11 @@ def element_to_objects(element: Union[Dict, Object]) -> List:
|
||||||
|
|
||||||
|
|
||||||
def model_to_objects(payload):
|
def model_to_objects(payload):
|
||||||
|
original_payload = copy.copy(payload)
|
||||||
model = globals().get(payload.get('type'))
|
model = globals().get(payload.get('type'))
|
||||||
if model and issubclass(model, Object):
|
if model and issubclass(model, Object):
|
||||||
try:
|
try:
|
||||||
entity = model.schema().load(payload)
|
entity = model.schema().load(payload)
|
||||||
entity._source_object = payload
|
|
||||||
except (KeyError, jsonld.JsonLdError, exceptions.ValidationError) as exc : # Just give up for now. This must be made robust
|
except (KeyError, jsonld.JsonLdError, exceptions.ValidationError) as exc : # Just give up for now. This must be made robust
|
||||||
logger.error(f"Error parsing jsonld payload ({exc})")
|
logger.error(f"Error parsing jsonld payload ({exc})")
|
||||||
return None
|
return None
|
||||||
|
@ -1397,6 +1361,19 @@ def model_to_objects(payload):
|
||||||
if isinstance(getattr(entity, 'object_', None), Object):
|
if isinstance(getattr(entity, 'object_', None), Object):
|
||||||
entity.object_.activity = entity
|
entity.object_.activity = entity
|
||||||
entity = entity.object_
|
entity = entity.object_
|
||||||
|
|
||||||
|
entity._source_object = original_payload
|
||||||
return entity
|
return entity
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
CLASSES_WITH_CONTEXT_EXTENSIONS = (
|
||||||
|
Document,
|
||||||
|
Emoji,
|
||||||
|
Hashtag,
|
||||||
|
IdentityProof,
|
||||||
|
Note,
|
||||||
|
Person,
|
||||||
|
PropertyValue
|
||||||
|
)
|
||||||
|
context_manager = LdContextManager(CLASSES_WITH_CONTEXT_EXTENSIONS)
|
|
@ -5,6 +5,7 @@ import warnings
|
||||||
from typing import List, Set, Union, Dict, Tuple
|
from typing import List, Set, Union, Dict, Tuple
|
||||||
|
|
||||||
from commonmark import commonmark
|
from commonmark import commonmark
|
||||||
|
from marshmallow import missing
|
||||||
|
|
||||||
from federation.entities.activitypub.enums import ActivityType
|
from federation.entities.activitypub.enums import ActivityType
|
||||||
from federation.entities.utils import get_name_for_profile, get_profile
|
from federation.entities.utils import get_name_for_profile, get_profile
|
||||||
|
@ -100,7 +101,7 @@ class BaseEntity:
|
||||||
def _validate_required(self, attributes):
|
def _validate_required(self, attributes):
|
||||||
"""Ensure required attributes are present."""
|
"""Ensure required attributes are present."""
|
||||||
required_fulfilled = set(self._required).issubset(set(attributes))
|
required_fulfilled = set(self._required).issubset(set(attributes))
|
||||||
if not required_fulfilled:
|
if not required_fulfilled or required_fulfilled is missing:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Not all required attributes fulfilled. Required: {required}".format(required=set(self._required))
|
"Not all required attributes fulfilled. Required: {required}".format(required=set(self._required))
|
||||||
)
|
)
|
||||||
|
@ -115,7 +116,7 @@ class BaseEntity:
|
||||||
attrs_to_check = set(self._required) & set(attributes)
|
attrs_to_check = set(self._required) & set(attributes)
|
||||||
for attr in attrs_to_check:
|
for attr in attrs_to_check:
|
||||||
value = getattr(self, attr) # We should always have a value here
|
value = getattr(self, attr) # We should always have a value here
|
||||||
if value is None or value == "":
|
if value is None or value == "" or value is missing:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Attribute %s cannot be None or an empty string since it is required." % attr
|
"Attribute %s cannot be None or an empty string since it is required." % attr
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,7 +15,9 @@ def get_base_attributes(entity, keep=()):
|
||||||
cls = entity.__class__
|
cls = entity.__class__
|
||||||
for attr, _ in inspect.getmembers(cls, lambda o: not isinstance(o, property) and not inspect.isroutine(o)):
|
for attr, _ in inspect.getmembers(cls, lambda o: not isinstance(o, property) and not inspect.isroutine(o)):
|
||||||
if not attr.startswith("_") or attr in keep:
|
if not attr.startswith("_") or attr in keep:
|
||||||
attributes[attr] = getattr(entity, attr)
|
value = getattr(entity, attr)
|
||||||
|
if value or isinstance(value, bool):
|
||||||
|
attributes[attr] = value
|
||||||
return attributes
|
return attributes
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ from Crypto.PublicKey.RSA import RsaKey
|
||||||
|
|
||||||
from federation.entities.activitypub.enums import ActorType
|
from federation.entities.activitypub.enums import ActorType
|
||||||
from federation.entities.mixins import BaseEntity
|
from federation.entities.mixins import BaseEntity
|
||||||
from federation.protocols.activitypub.signing import verify_request_signature, verify_ld_signature, create_ld_signature
|
from federation.protocols.activitypub.signing import verify_request_signature
|
||||||
from federation.types import UserType, RequestType
|
from federation.types import UserType, RequestType
|
||||||
from federation.utils.text import decode_if_bytes
|
from federation.utils.text import decode_if_bytes
|
||||||
|
|
||||||
|
@ -43,6 +43,7 @@ class Protocol:
|
||||||
get_contact_key = None
|
get_contact_key = None
|
||||||
payload = None
|
payload = None
|
||||||
request = None
|
request = None
|
||||||
|
sender = None
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
def build_send(self, entity: BaseEntity, from_user: UserType, to_user_key: RsaKey = None) -> Union[str, Dict]:
|
def build_send(self, entity: BaseEntity, from_user: UserType, to_user_key: RsaKey = None) -> Union[str, Dict]:
|
||||||
|
@ -58,8 +59,7 @@ class Protocol:
|
||||||
# Use pregenerated outbound document
|
# Use pregenerated outbound document
|
||||||
rendered = entity.outbound_doc
|
rendered = entity.outbound_doc
|
||||||
else:
|
else:
|
||||||
rendered = entity.to_as2()
|
rendered = entity.sign_as2(sender=from_user)
|
||||||
create_ld_signature(rendered, from_user)
|
|
||||||
return rendered
|
return rendered
|
||||||
|
|
||||||
def extract_actor(self):
|
def extract_actor(self):
|
||||||
|
@ -87,14 +87,9 @@ class Protocol:
|
||||||
# Verify the message is from who it claims to be
|
# Verify the message is from who it claims to be
|
||||||
if not skip_author_verification:
|
if not skip_author_verification:
|
||||||
try:
|
try:
|
||||||
self.verify_signature()
|
# Verify the HTTP signature
|
||||||
|
self.sender = verify_request_signature(self.request)
|
||||||
except (ValueError, KeyError, InvalidSignature) as exc:
|
except (ValueError, KeyError, InvalidSignature) as exc:
|
||||||
logger.warning(f'Signature verification failed: {exc}')
|
logger.warning(f'HTTP signature verification failed: {exc}')
|
||||||
return self.actor, {}
|
return self.actor, {}
|
||||||
return self.actor, self.payload
|
return self.sender, self.payload
|
||||||
|
|
||||||
def verify_signature(self):
|
|
||||||
# Verify the HTTP signature
|
|
||||||
self.actor = verify_request_signature(self.request)
|
|
||||||
# Verify the LD signature (not currently enforced)
|
|
||||||
verify_ld_signature(self.payload)
|
|
||||||
|
|
|
@ -5,24 +5,14 @@ https://funkwhale.audio/
|
||||||
"""
|
"""
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from base64 import b64encode, b64decode
|
|
||||||
from copy import copy
|
|
||||||
from funcy import omit
|
|
||||||
from pyld import jsonld
|
|
||||||
from typing import Union
|
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from Crypto.Hash import SHA256
|
from Crypto.PublicKey.RSA import RsaKey
|
||||||
from Crypto.PublicKey.RSA import RsaKey, import_key
|
|
||||||
from Crypto.Signature import pkcs1_15
|
|
||||||
from httpsig.sign_algorithms import PSS
|
from httpsig.sign_algorithms import PSS
|
||||||
from httpsig.requests_auth import HTTPSignatureAuth
|
from httpsig.requests_auth import HTTPSignatureAuth
|
||||||
from httpsig.verify import HeaderVerifier
|
from httpsig.verify import HeaderVerifier
|
||||||
|
|
||||||
import federation.utils.jsonld_helper
|
|
||||||
from federation.types import RequestType
|
from federation.types import RequestType
|
||||||
from federation.utils.network import parse_http_date
|
from federation.utils.network import parse_http_date
|
||||||
from federation.utils.text import encode_if_text
|
from federation.utils.text import encode_if_text
|
||||||
|
@ -91,87 +81,7 @@ def verify_request_signature(request: RequestType, required: bool=True):
|
||||||
return signer.id
|
return signer.id
|
||||||
|
|
||||||
|
|
||||||
def create_ld_signature(obj, author):
|
|
||||||
# Use models.Signature? Maybe overkill...
|
|
||||||
sig = {
|
|
||||||
'created': datetime.datetime.now(tz=datetime.timezone.utc).isoformat(timespec='seconds'),
|
|
||||||
'creator': f'{author.id}#main-key',
|
|
||||||
'@context':'https://w3id.org/security/v1'
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
private_key = import_key(author.private_key)
|
|
||||||
except (ValueError, TypeError) as exc:
|
|
||||||
logger.warning(f'ld_signature - {exc}')
|
|
||||||
return None
|
|
||||||
signer = pkcs1_15.new(private_key)
|
|
||||||
|
|
||||||
sig_nquads = normalize(sig, options={'format':'application/nquads','algorithm':'URDNA2015'}).encode('utf-8')
|
|
||||||
sig_digest = SHA256.new(sig_nquads).hexdigest()
|
|
||||||
obj_nquads = normalize(obj, options={'format':'application/nquads','algorithm':'URDNA2015'}).encode('utf-8')
|
|
||||||
obj_digest = SHA256.new(obj_nquads).hexdigest()
|
|
||||||
digest = (sig_digest + obj_digest).encode('utf-8')
|
|
||||||
|
|
||||||
signature = signer.sign(SHA256.new(digest))
|
|
||||||
sig.update({'type': 'RsaSignature2017', 'signatureValue': b64encode(signature).decode()})
|
|
||||||
sig.pop('@context')
|
|
||||||
|
|
||||||
obj.update({'signature':sig})
|
|
||||||
|
|
||||||
def verify_ld_signature(payload):
|
|
||||||
"""
|
|
||||||
Verify inbound payload LD signature
|
|
||||||
"""
|
|
||||||
signature = copy(payload.get('signature'))
|
|
||||||
if not signature:
|
|
||||||
logger.warning('ld_signature - No LD signature in the payload')
|
|
||||||
return None # Maybe False would be better?
|
|
||||||
|
|
||||||
# retrieve the author's public key
|
|
||||||
from federation.utils.activitypub import retrieve_and_parse_document
|
|
||||||
|
|
||||||
profile = retrieve_and_parse_document(signature.get('creator'))
|
|
||||||
if not profile:
|
|
||||||
logger.warning(f'ld_signature - Failed to retrieve profile for {signature.get("creator")}')
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
pkey = import_key(profile.public_key)
|
|
||||||
except ValueError as exc:
|
|
||||||
logger.warning(f'ld_signature - {exc}')
|
|
||||||
return None
|
|
||||||
verifier = pkcs1_15.new(pkey)
|
|
||||||
|
|
||||||
# Compute digests and verify signature
|
|
||||||
sig = omit(signature, ('type', 'signatureValue'))
|
|
||||||
sig.update({'@context':'https://w3id.org/security/v1'})
|
|
||||||
sig_nquads = normalize(sig, options={'format':'application/nquads','algorithm':'URDNA2015'}).encode('utf-8')
|
|
||||||
sig_digest = SHA256.new(sig_nquads).hexdigest()
|
|
||||||
obj = omit(payload, 'signature')
|
|
||||||
obj_nquads = normalize(obj, options={'format':'application/nquads','algorithm':'URDNA2015'}).encode('utf-8')
|
|
||||||
obj_digest = SHA256.new(obj_nquads).hexdigest()
|
|
||||||
digest = (sig_digest + obj_digest).encode('utf-8')
|
|
||||||
|
|
||||||
sig_value = b64decode(signature.get('signatureValue'))
|
|
||||||
try:
|
|
||||||
verifier.verify(SHA256.new(digest), sig_value)
|
|
||||||
logger.debug(f'ld_signature - {payload.get("id")} has a valid signature')
|
|
||||||
except ValueError as exc:
|
|
||||||
logger.warning(f'ld_signature - invalid signature for {payload.get("id")}')
|
|
||||||
|
|
||||||
|
|
||||||
# We need this to ensure the digests are identical.
|
|
||||||
def normalize(input_, options):
|
|
||||||
return NormalizedDoubles().normalize(input_, options)
|
|
||||||
|
|
||||||
class NormalizedDoubles(jsonld.JsonLdProcessor):
|
|
||||||
def _object_to_rdf(self, item, issuer, triples, rdfDirection):
|
|
||||||
value = item['@value'] if jsonld._is_value(item) else None
|
|
||||||
# The ruby rdf_normalize library turns floats with a zero fraction to integers.
|
|
||||||
if isinstance(value, float) and value == math.floor(value):
|
|
||||||
item['@value'] = math.floor(value)
|
|
||||||
obj = super()._object_to_rdf(item, issuer, triples, rdfDirection)
|
|
||||||
# This is to address https://github.com/digitalbazaar/pyld/issues/175
|
|
||||||
if obj.get('datatype') == jsonld.XSD_DOUBLE:
|
|
||||||
obj['value'] = re.sub(r'(\d)0*E\+?(-)?0*(\d)', r'\1E\2\3', obj['value'])
|
|
||||||
|
|
||||||
return obj
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ from pprint import pprint
|
||||||
# noinspection PyPackageRequirements
|
# noinspection PyPackageRequirements
|
||||||
from Crypto.PublicKey.RSA import RsaKey
|
from Crypto.PublicKey.RSA import RsaKey
|
||||||
|
|
||||||
from federation.entities.activitypub.constants import CONTEXT
|
from federation.entities.activitypub.models import context_manager
|
||||||
from federation.entities.activitypub.models import Accept
|
from federation.entities.activitypub.models import Accept
|
||||||
from federation.tests.fixtures.keys import PUBKEY
|
from federation.tests.fixtures.keys import PUBKEY
|
||||||
from federation.types import UserType
|
from federation.types import UserType
|
||||||
|
@ -15,7 +15,7 @@ class TestEntitiesConvertToAS2:
|
||||||
def test_accept_to_as2(self, activitypubaccept):
|
def test_accept_to_as2(self, activitypubaccept):
|
||||||
result = activitypubaccept.to_as2()
|
result = activitypubaccept.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
"@context": CONTEXT,
|
"@context": context_manager.build_context(activitypubaccept),
|
||||||
"id": "https://localhost/accept",
|
"id": "https://localhost/accept",
|
||||||
"type": "Accept",
|
"type": "Accept",
|
||||||
"actor": "https://localhost/profile",
|
"actor": "https://localhost/profile",
|
||||||
|
@ -30,7 +30,7 @@ class TestEntitiesConvertToAS2:
|
||||||
def test_announce_to_as2(self, activitypubannounce):
|
def test_announce_to_as2(self, activitypubannounce):
|
||||||
result = activitypubannounce.to_as2()
|
result = activitypubannounce.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
"@context": CONTEXT,
|
"@context": context_manager.build_context(activitypubannounce),
|
||||||
"id": "http://127.0.0.1:8000/post/123456/#create",
|
"id": "http://127.0.0.1:8000/post/123456/#create",
|
||||||
"type": "Announce",
|
"type": "Announce",
|
||||||
"actor": "http://127.0.0.1:8000/profile/123456/",
|
"actor": "http://127.0.0.1:8000/profile/123456/",
|
||||||
|
@ -42,13 +42,14 @@ class TestEntitiesConvertToAS2:
|
||||||
activitypubcomment.pre_send()
|
activitypubcomment.pre_send()
|
||||||
result = activitypubcomment.to_as2()
|
result = activitypubcomment.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubcomment),
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'object': {
|
'object': {
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/',
|
'id': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
|
'url': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'content': '<p>raw_content</p>',
|
'content': '<p>raw_content</p>',
|
||||||
'published': '2019-04-27T00:00:00',
|
'published': '2019-04-27T00:00:00',
|
||||||
|
@ -67,13 +68,14 @@ class TestEntitiesConvertToAS2:
|
||||||
activitypubcomment.pre_send()
|
activitypubcomment.pre_send()
|
||||||
result = activitypubcomment.to_as2()
|
result = activitypubcomment.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubcomment),
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'object': {
|
'object': {
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/',
|
'id': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
|
'url': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'content': '<p>raw_content <a href="http://example.com" rel="nofollow" target="_blank">'
|
'content': '<p>raw_content <a href="http://example.com" rel="nofollow" target="_blank">'
|
||||||
'http://example.com</a></p>',
|
'http://example.com</a></p>',
|
||||||
|
@ -91,7 +93,7 @@ class TestEntitiesConvertToAS2:
|
||||||
def test_follow_to_as2(self, activitypubfollow):
|
def test_follow_to_as2(self, activitypubfollow):
|
||||||
result = activitypubfollow.to_as2()
|
result = activitypubfollow.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
"@context": CONTEXT,
|
"@context": context_manager.build_context(activitypubfollow),
|
||||||
"id": "https://localhost/follow",
|
"id": "https://localhost/follow",
|
||||||
"type": "Follow",
|
"type": "Follow",
|
||||||
"actor": "https://localhost/profile",
|
"actor": "https://localhost/profile",
|
||||||
|
@ -103,7 +105,7 @@ class TestEntitiesConvertToAS2:
|
||||||
result["id"] = "https://localhost/undo" # Real object will have a random UUID postfix here
|
result["id"] = "https://localhost/undo" # Real object will have a random UUID postfix here
|
||||||
result["object"]["id"] = "https://localhost/follow" # Real object will have a random UUID postfix here
|
result["object"]["id"] = "https://localhost/follow" # Real object will have a random UUID postfix here
|
||||||
assert result == {
|
assert result == {
|
||||||
"@context": CONTEXT,
|
"@context": context_manager.build_context(activitypubundofollow),
|
||||||
"id": "https://localhost/undo",
|
"id": "https://localhost/undo",
|
||||||
"type": "Undo",
|
"type": "Undo",
|
||||||
"actor": "https://localhost/profile",
|
"actor": "https://localhost/profile",
|
||||||
|
@ -119,7 +121,7 @@ class TestEntitiesConvertToAS2:
|
||||||
activitypubpost.pre_send()
|
activitypubpost.pre_send()
|
||||||
result = activitypubpost.to_as2()
|
result = activitypubpost.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubpost),
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
|
@ -130,6 +132,7 @@ class TestEntitiesConvertToAS2:
|
||||||
'cc': ['https://http://127.0.0.1:8000/profile/123456/followers/'],
|
'cc': ['https://http://127.0.0.1:8000/profile/123456/followers/'],
|
||||||
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
|
'url': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'content': '<h1>raw_content</h1>',
|
'content': '<h1>raw_content</h1>',
|
||||||
'published': '2019-04-27T00:00:00',
|
'published': '2019-04-27T00:00:00',
|
||||||
|
@ -148,13 +151,14 @@ class TestEntitiesConvertToAS2:
|
||||||
activitypubpost_mentions.pre_send()
|
activitypubpost_mentions.pre_send()
|
||||||
result = activitypubpost_mentions.to_as2()
|
result = activitypubpost_mentions.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubpost_mentions),
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'object': {
|
'object': {
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/',
|
'id': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
|
'url': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'content': '<h1>raw_content</h1>\n<p>@{someone@localhost.local} @<a class="mention" '
|
'content': '<h1>raw_content</h1>\n<p>@{someone@localhost.local} @<a class="mention" '
|
||||||
'href="http://localhost.local/someone" rel="nofollow" target="_blank">'
|
'href="http://localhost.local/someone" rel="nofollow" target="_blank">'
|
||||||
|
@ -190,13 +194,14 @@ class TestEntitiesConvertToAS2:
|
||||||
activitypubpost_tags.pre_send()
|
activitypubpost_tags.pre_send()
|
||||||
result = activitypubpost_tags.to_as2()
|
result = activitypubpost_tags.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubpost_tags),
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'object': {
|
'object': {
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/',
|
'id': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
|
'url': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'content': '<h1>raw_content</h1>\n'
|
'content': '<h1>raw_content</h1>\n'
|
||||||
'<p><a class="mention hashtag" '
|
'<p><a class="mention hashtag" '
|
||||||
|
@ -233,13 +238,14 @@ class TestEntitiesConvertToAS2:
|
||||||
activitypubpost_images.pre_send()
|
activitypubpost_images.pre_send()
|
||||||
result = activitypubpost_images.to_as2()
|
result = activitypubpost_images.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubpost_images),
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'object': {
|
'object': {
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/',
|
'id': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
|
'url': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'content': '<p>raw_content</p>',
|
'content': '<p>raw_content</p>',
|
||||||
'published': '2019-04-27T00:00:00',
|
'published': '2019-04-27T00:00:00',
|
||||||
|
@ -271,7 +277,7 @@ class TestEntitiesConvertToAS2:
|
||||||
activitypubpost_diaspora_guid.pre_send()
|
activitypubpost_diaspora_guid.pre_send()
|
||||||
result = activitypubpost_diaspora_guid.to_as2()
|
result = activitypubpost_diaspora_guid.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubpost_diaspora_guid),
|
||||||
'type': 'Create',
|
'type': 'Create',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
'id': 'http://127.0.0.1:8000/post/123456/#create',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
|
@ -279,6 +285,7 @@ class TestEntitiesConvertToAS2:
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/',
|
'id': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'diaspora:guid': 'totallyrandomguid',
|
'diaspora:guid': 'totallyrandomguid',
|
||||||
'type': 'Note',
|
'type': 'Note',
|
||||||
|
'url': 'http://127.0.0.1:8000/post/123456/',
|
||||||
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
'attributedTo': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
'content': '<p>raw_content</p>',
|
'content': '<p>raw_content</p>',
|
||||||
'published': '2019-04-27T00:00:00',
|
'published': '2019-04-27T00:00:00',
|
||||||
|
@ -295,7 +302,7 @@ class TestEntitiesConvertToAS2:
|
||||||
def test_profile_to_as2(self, activitypubprofile):
|
def test_profile_to_as2(self, activitypubprofile):
|
||||||
result = activitypubprofile.to_as2()
|
result = activitypubprofile.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
"@context": CONTEXT,
|
"@context": context_manager.build_context(activitypubprofile),
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"sharedInbox": "https://example.com/public",
|
"sharedInbox": "https://example.com/public",
|
||||||
},
|
},
|
||||||
|
@ -327,7 +334,7 @@ class TestEntitiesConvertToAS2:
|
||||||
def test_profile_to_as2__with_diaspora_guid(self, activitypubprofile_diaspora_guid):
|
def test_profile_to_as2__with_diaspora_guid(self, activitypubprofile_diaspora_guid):
|
||||||
result = activitypubprofile_diaspora_guid.to_as2()
|
result = activitypubprofile_diaspora_guid.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
"@context": CONTEXT,
|
"@context": context_manager.build_context(activitypubprofile_diaspora_guid),
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"sharedInbox": "https://example.com/public",
|
"sharedInbox": "https://example.com/public",
|
||||||
},
|
},
|
||||||
|
@ -360,7 +367,7 @@ class TestEntitiesConvertToAS2:
|
||||||
def test_retraction_to_as2(self, activitypubretraction):
|
def test_retraction_to_as2(self, activitypubretraction):
|
||||||
result = activitypubretraction.to_as2()
|
result = activitypubretraction.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubretraction),
|
||||||
'type': 'Delete',
|
'type': 'Delete',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#delete',
|
'id': 'http://127.0.0.1:8000/post/123456/#delete',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
|
@ -374,7 +381,7 @@ class TestEntitiesConvertToAS2:
|
||||||
def test_retraction_to_as2__announce(self, activitypubretraction_announce):
|
def test_retraction_to_as2__announce(self, activitypubretraction_announce):
|
||||||
result = activitypubretraction_announce.to_as2()
|
result = activitypubretraction_announce.to_as2()
|
||||||
assert result == {
|
assert result == {
|
||||||
'@context': CONTEXT,
|
'@context': context_manager.build_context(activitypubretraction_announce),
|
||||||
'type': 'Undo',
|
'type': 'Undo',
|
||||||
'id': 'http://127.0.0.1:8000/post/123456/#delete',
|
'id': 'http://127.0.0.1:8000/post/123456/#delete',
|
||||||
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
'actor': 'http://127.0.0.1:8000/profile/123456/',
|
||||||
|
|
|
@ -187,7 +187,7 @@ class TestActivitypubEntityMappersReceive:
|
||||||
"private": "https://diaspodon.fr/users/jaywink/inbox",
|
"private": "https://diaspodon.fr/users/jaywink/inbox",
|
||||||
"public": "https://diaspodon.fr/inbox",
|
"public": "https://diaspodon.fr/inbox",
|
||||||
}
|
}
|
||||||
assert profile.handle == ""
|
assert profile.handle == None
|
||||||
assert profile.name == "Jason Robinson"
|
assert profile.name == "Jason Robinson"
|
||||||
assert profile.image_urls == {
|
assert profile.image_urls == {
|
||||||
"large": "https://diaspodon.fr/system/accounts/avatars/000/033/155/original/pnc__picked_media_be51984c-4"
|
"large": "https://diaspodon.fr/system/accounts/avatars/000/033/155/original/pnc__picked_media_be51984c-4"
|
||||||
|
|
|
@ -13,22 +13,17 @@ from federation.entities.utils import get_base_attributes
|
||||||
|
|
||||||
|
|
||||||
class TestGetBaseAttributes:
|
class TestGetBaseAttributes:
|
||||||
def test_get_base_attributes_returns_only_intended_attributes(self):
|
def test_get_base_attributes_returns_only_intended_attributes(self, diasporapost, diasporaprofile):
|
||||||
entity = Post()
|
entity = diasporapost
|
||||||
attrs = get_base_attributes(entity).keys()
|
attrs = get_base_attributes(entity).keys()
|
||||||
assert set(attrs) == {
|
assert set(attrs) == {
|
||||||
"created_at", "location", "provider_display_name", "public", "raw_content",
|
'activity', 'actor_id', 'created_at', 'guid', 'handle', 'id',
|
||||||
"signature", "base_url", "actor_id", "id", "handle", "guid", "activity", "activity_id",
|
'provider_display_name', 'public', 'raw_content'}
|
||||||
"url", "mxid", "times", "to", "cc", "finger",
|
entity = diasporaprofile
|
||||||
}
|
|
||||||
entity = Profile()
|
|
||||||
attrs = get_base_attributes(entity).keys()
|
attrs = get_base_attributes(entity).keys()
|
||||||
assert set(attrs) == {
|
assert set(attrs) == {
|
||||||
"created_at", "name", "email", "gender", "raw_content", "location", "public",
|
'created_at', 'guid', 'handle', 'id', 'image_urls', 'inboxes',
|
||||||
"nsfw", "public_key", "image_urls", "tag_list", "signature", "url", "atom_url",
|
'name', 'nsfw', 'public', 'raw_content', 'tag_list'}
|
||||||
"base_url", "id", "actor_id", "handle", "handle", "guid", "activity", "activity_id", "username",
|
|
||||||
"inboxes", "mxid", "times", "to", "cc", "finger",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetFullXMLRepresentation:
|
class TestGetFullXMLRepresentation:
|
||||||
|
|
|
@ -79,11 +79,13 @@ class TestRetrieveAndParseDocument:
|
||||||
assert entity._children[0].url == "https://files.mastodon.social/media_attachments/files/017/792/237/original" \
|
assert entity._children[0].url == "https://files.mastodon.social/media_attachments/files/017/792/237/original" \
|
||||||
"/foobar.jpg"
|
"/foobar.jpg"
|
||||||
|
|
||||||
|
@patch("federation.entities.activitypub.models.verify_ld_signature", return_value=None)
|
||||||
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
|
@patch("federation.entities.activitypub.models.extract_receivers", return_value=[])
|
||||||
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
|
@patch("federation.utils.activitypub.fetch_document", autospec=True, return_value=(
|
||||||
json.dumps(ACTIVITYPUB_POST), None, None),
|
json.dumps(ACTIVITYPUB_POST), None, None),
|
||||||
)
|
)
|
||||||
def test_returns_entity_for_valid_document__post__wrapped_in_activity(self, mock_fetch, mock_recv):
|
def test_returns_entity_for_valid_document__post__wrapped_in_activity(
|
||||||
|
self, mock_fetch, mock_recv, mock_sign):
|
||||||
entity = retrieve_and_parse_document("https://example.com/foobar")
|
entity = retrieve_and_parse_document("https://example.com/foobar")
|
||||||
assert isinstance(entity, Note)
|
assert isinstance(entity, Note)
|
||||||
|
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
import json
|
|
||||||
from datetime import timedelta
|
|
||||||
from pyld import jsonld
|
|
||||||
|
|
||||||
from federation.utils.django import get_redis
|
|
||||||
|
|
||||||
|
|
||||||
cache = get_redis() or {}
|
|
||||||
EXPIRATION = int(timedelta(weeks=4).total_seconds())
|
|
||||||
|
|
||||||
|
|
||||||
# This is required to workaround a bug in pyld that has the Accept header
|
|
||||||
# accept other content types. From what I understand, precedence handling
|
|
||||||
# is broken
|
|
||||||
# from https://github.com/digitalbazaar/pyld/issues/133
|
|
||||||
# cacheing loosely inspired by https://github.com/digitalbazaar/pyld/issues/70
|
|
||||||
def get_loader(*args, **kwargs):
|
|
||||||
requests_loader = jsonld.requests_document_loader(*args, **kwargs)
|
|
||||||
|
|
||||||
def loader(url, options={}):
|
|
||||||
key = f'ld_cache:{url}'
|
|
||||||
try:
|
|
||||||
return json.loads(cache[key])
|
|
||||||
except KeyError:
|
|
||||||
options['headers']['Accept'] = 'application/ld+json'
|
|
||||||
doc = requests_loader(url, options)
|
|
||||||
if isinstance(cache, dict):
|
|
||||||
cache[url] = json.dumps(doc)
|
|
||||||
else:
|
|
||||||
cache.set(f'ld_cache:{url}', json.dumps(doc), ex=EXPIRATION)
|
|
||||||
return doc
|
|
||||||
|
|
||||||
return loader
|
|
||||||
|
|
||||||
|
|
||||||
jsonld.set_document_loader(get_loader())
|
|
Ładowanie…
Reference in New Issue