Skip to content
Snippets Groups Projects
Commit 12c9db39 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Ensures federation urls can answer to application/ld+json,...

Ensures federation urls can answer to application/ld+json, application/activity+json and application/json requests
parent c9e7eea6
No related branches found
No related tags found
No related merge requests found
Showing
with 1294 additions and 1001 deletions
...@@ -193,3 +193,11 @@ def replace_prefix(queryset, field, old, new): ...@@ -193,3 +193,11 @@ def replace_prefix(queryset, field, old, new):
models.functions.Substr(field, len(old) + 1, output_field=models.CharField()), models.functions.Substr(field, len(old) + 1, output_field=models.CharField()),
) )
return qs.update(**{field: update}) return qs.update(**{field: update})
def concat_dicts(*dicts):
n = {}
for d in dicts:
n.update(d)
return n
...@@ -9,11 +9,13 @@ from django.db.models import Q ...@@ -9,11 +9,13 @@ from django.db.models import Q
from funkwhale_api.common import channels from funkwhale_api.common import channels
from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common import utils as funkwhale_utils
from . import contexts
recursive_getattr = funkwhale_utils.recursive_getattr recursive_getattr = funkwhale_utils.recursive_getattr
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public" PUBLIC_ADDRESS = contexts.AS.Public
ACTIVITY_TYPES = [ ACTIVITY_TYPES = [
"Accept", "Accept",
...@@ -84,7 +86,10 @@ OBJECT_TYPES = ( ...@@ -84,7 +86,10 @@ OBJECT_TYPES = (
BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"] BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"]
def should_reject(id, actor_id=None, payload={}): def should_reject(fid, actor_id=None, payload={}):
if fid is None and actor_id is None:
return False
from funkwhale_api.moderation import models as moderation_models from funkwhale_api.moderation import models as moderation_models
policies = moderation_models.InstancePolicy.objects.active() policies = moderation_models.InstancePolicy.objects.active()
...@@ -102,9 +107,12 @@ def should_reject(id, actor_id=None, payload={}): ...@@ -102,9 +107,12 @@ def should_reject(id, actor_id=None, payload={}):
else: else:
policy_type = Q(block_all=True) policy_type = Q(block_all=True)
query = policies.matching_url_query(id) & policy_type if fid:
if actor_id: query = policies.matching_url_query(fid) & policy_type
if fid and actor_id:
query |= policies.matching_url_query(actor_id) & policy_type query |= policies.matching_url_query(actor_id) & policy_type
elif actor_id:
query = policies.matching_url_query(actor_id) & policy_type
return policies.filter(query).exists() return policies.filter(query).exists()
...@@ -121,7 +129,7 @@ def receive(activity, on_behalf_of): ...@@ -121,7 +129,7 @@ def receive(activity, on_behalf_of):
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
if should_reject( if should_reject(
id=serializer.validated_data["id"], fid=serializer.validated_data.get("id"),
actor_id=serializer.validated_data["actor"].fid, actor_id=serializer.validated_data["actor"].fid,
payload=activity, payload=activity,
): ):
......
CONTEXTS = [
{
"shortId": "LDP",
"contextUrl": None,
"documentUrl": "http://www.w3.org/ns/ldp",
"document": {
"@context": {
"ldp": "http://www.w3.org/ns/ldp#",
"id": "@id",
"type": "@type",
"Container": "ldp:Container",
"BasicContainer": "ldp:BasicContainer",
"DirectContainer": "ldp:DirectContainer",
"IndirectContainer": "ldp:IndirectContainer",
"hasMemberRelation": {"@id": "ldp:hasMemberRelation", "@type": "@id"},
"isMemberOfRelation": {"@id": "ldp:isMemberOfRelation", "@type": "@id"},
"membershipResource": {"@id": "ldp:membershipResource", "@type": "@id"},
"insertedContentRelation": {
"@id": "ldp:insertedContentRelation",
"@type": "@id",
},
"contains": {"@id": "ldp:contains", "@type": "@id"},
"member": {"@id": "ldp:member", "@type": "@id"},
"constrainedBy": {"@id": "ldp:constrainedBy", "@type": "@id"},
"Resource": "ldp:Resource",
"RDFSource": "ldp:RDFSource",
"NonRDFSource": "ldp:NonRDFSource",
"MemberSubject": "ldp:MemberSubject",
"PreferContainment": "ldp:PreferContainment",
"PreferMembership": "ldp:PreferMembership",
"PreferMinimalContainer": "ldp:PreferMinimalContainer",
"PageSortCriterion": "ldp:PageSortCriterion",
"pageSortCriteria": {
"@id": "ldp:pageSortCriteria",
"@type": "@id",
"@container": "@list",
},
"pageSortPredicate": {"@id": "ldp:pageSortPredicate", "@type": "@id"},
"pageSortOrder": {"@id": "ldp:pageSortOrder", "@type": "@id"},
"pageSortCollation": {"@id": "ldp:pageSortCollation", "@type": "@id"},
"Ascending": "ldp:Ascending",
"Descending": "ldp:Descending",
"Page": "ldp:Page",
"pageSequence": {"@id": "ldp:pageSequence", "@type": "@id"},
"inbox": {"@id": "ldp:inbox", "@type": "@id"},
}
},
},
{
"shortId": "AS",
"contextUrl": None,
"documentUrl": "https://www.w3.org/ns/activitystreams",
"document": {
"@context": {
"@vocab": "_:",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"as": "https://www.w3.org/ns/activitystreams#",
"ldp": "http://www.w3.org/ns/ldp#",
"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"},
# Added manually
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
}
},
},
{
"shortId": "SEC",
"contextUrl": None,
"documentUrl": "https://w3id.org/security/v1",
"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",
}
},
},
{
"shortId": "FW",
"contextUrl": None,
"documentUrl": "https://funkwhale.audio/ns",
"document": {
"@context": {
"id": "@id",
"type": "@type",
"as": "https://www.w3.org/ns/activitystreams#",
"fw": "https://funkwhale.audio/ns#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"Album": "fw:Album",
"Track": "fw:Track",
"Artist": "fw:Artist",
"Library": "fw:Library",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},
"disc": {"@id": "fw:disc", "@type": "xsd:nonNegativeInteger"},
"library": {"@id": "fw:library", "@type": "@id"},
"track": {"@id": "fw:track", "@type": "@id"},
"cover": {"@id": "fw:cover", "@type": "as:Link"},
"album": {"@id": "fw:album", "@type": "@id"},
"artists": {"@id": "fw:artists", "@type": "@id", "@container": "@list"},
"released": {"@id": "fw:released", "@type": "xsd:date"},
"musicbrainzId": "fw:musicbrainzId",
"license": {"@id": "fw:license", "@type": "@id"},
"copyright": "fw:copyright",
}
},
},
]
CONTEXTS_BY_ID = {c["shortId"]: c for c in CONTEXTS}
class NS:
def __init__(self, conf):
self.conf = conf
self.baseUrl = self.conf["document"]["@context"][self.conf["shortId"].lower()]
def __repr__(self):
return "<{}: {}>".format(self.conf["shortId"], self.baseUrl)
def __getattr__(self, key):
if key not in self.conf["document"]["@context"]:
raise AttributeError(
"{} is not a valid property of context {}".format(key, self.baseUrl)
)
return self.baseUrl + key
class NoopContext:
def __getattr__(self, key):
return "_:{}".format(key)
NOOP = NoopContext()
AS = NS(CONTEXTS_BY_ID["AS"])
LDP = NS(CONTEXTS_BY_ID["LDP"])
SEC = NS(CONTEXTS_BY_ID["SEC"])
FW = NS(CONTEXTS_BY_ID["FW"])
import aiohttp
import asyncio
import functools
import pyld.jsonld
from django.conf import settings
import pyld.documentloader.requests
from rest_framework import serializers
from rest_framework.fields import empty
from . import contexts
def cached_contexts(loader):
functools.wraps(loader)
def load(url, *args, **kwargs):
for cached in contexts.CONTEXTS:
if url == cached["documentUrl"]:
return cached
return loader(url, *args, **kwargs)
return load
def get_document_loader():
loader = pyld.documentloader.requests.requests_document_loader(
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL
)
return cached_contexts(loader)
def expand(doc, options=None, insert_fw_context=True):
options = options or {}
options.setdefault("documentLoader", get_document_loader())
if isinstance(doc, str):
doc = options["documentLoader"](doc)["document"]
if insert_fw_context:
fw = contexts.CONTEXTS_BY_ID["FW"]["documentUrl"]
try:
insert_context(fw, doc)
except KeyError:
# probably an already expanded document
pass
result = pyld.jsonld.expand(doc, options=options)
try:
# jsonld.expand returns a list, which is useless for us
return result[0]
except IndexError:
raise ValueError("Impossible to expand this jsonld document")
def insert_context(ctx, doc):
"""
In some situations, we may want to add a default context to an existing document.
This function enable that (this will mutate the original document)
"""
existing = doc["@context"]
if isinstance(existing, list):
if ctx not in existing:
existing.append(ctx)
else:
doc["@context"] = [existing, ctx]
return doc
def get_session():
return aiohttp.ClientSession(raise_for_status=True)
async def fetch_json(url, session, cache=None, lock=None):
async with session.get(url) as response:
response.raise_for_status()
return url, await response.json()
async def fetch_many(*ids, references=None):
"""
Given a list of object ids, will fetch the remote
representations for those objects, expand them
and return a dictionnary with id as the key and expanded document as the values
"""
ids = set(ids)
results = references if references is not None else {}
if not ids:
return results
async with get_session() as session:
tasks = [fetch_json(url, session) for url in ids if url not in results]
tasks_results = await asyncio.gather(*tasks)
for url, payload in tasks_results:
results[url] = payload
return results
DEFAULT_PREPARE_CONFIG = {
"type": {"property": "@type", "keep": "first"},
"id": {"property": "@id"},
}
def dereference(value, references):
"""
Given a payload and a dictonary containing ids and objects, will replace
all the matching objects in the payload by the one in the references dictionary.
"""
def replace(obj, id):
try:
matching = references[id]
except KeyError:
return
# we clear the current dict, and replace its content by the matching obj
obj.clear()
obj.update(matching)
if isinstance(value, dict):
if "@id" in value:
replace(value, value["@id"])
else:
for attr in value.values():
dereference(attr, references)
elif isinstance(value, list):
# we loop on nested objects and trigger dereferencing
for obj in value:
dereference(obj, references)
return value
def get_value(value, keep=None, attr=None):
if keep == "first":
value = value[0]
if attr:
value = value[attr]
elif attr:
value = [obj[attr] for obj in value if attr in obj]
return value
def prepare_for_serializer(payload, config, fallbacks={}):
"""
Json-ld payloads, as returned by expand are quite complex to handle, because
every attr is basically a list of dictionnaries. To make code simpler,
we use this function to clean the payload a little bit, base on the config object.
Config is a dictionnary, with keys being serializer field names, and values
being dictionaries describing how to handle this field.
"""
final_payload = {}
final_config = {}
final_config.update(DEFAULT_PREPARE_CONFIG)
final_config.update(config)
for field, field_config in final_config.items():
try:
value = get_value(
payload[field_config["property"]],
keep=field_config.get("keep"),
attr=field_config.get("attr"),
)
except (IndexError, KeyError):
aliases = field_config.get("aliases", [])
noop = object()
value = noop
if not aliases:
continue
for a in aliases:
try:
value = get_value(
payload[a],
keep=field_config.get("keep"),
attr=field_config.get("attr"),
)
except (IndexError, KeyError):
continue
break
if value is noop:
continue
final_payload[field] = value
for key, choices in fallbacks.items():
if key in final_payload:
# initial attr was found, no need to rely on fallbacks
continue
for choice in choices:
if choice not in final_payload:
continue
final_payload[key] = final_payload[choice]
return final_payload
def get_ids(v):
if isinstance(v, dict) and "@id" in v:
yield v["@id"]
if isinstance(v, list):
for obj in v:
yield from get_ids(obj)
def get_default_context():
return ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}]
class JsonLdSerializer(serializers.Serializer):
def run_validation(self, data=empty):
if data and data is not empty and self.context.get("expand", True):
try:
data = expand(data)
except ValueError:
raise serializers.ValidationError(
"{} is not a valid jsonld document".format(data)
)
try:
config = self.Meta.jsonld_mapping
except AttributeError:
config = {}
try:
fallbacks = self.Meta.jsonld_fallbacks
except AttributeError:
fallbacks = {}
data = prepare_for_serializer(data, config, fallbacks=fallbacks)
dereferenced_fields = [
k
for k, c in config.items()
if k in data and c.get("dereference", False)
]
dereferenced_ids = set()
for field in dereferenced_fields:
for i in get_ids(data[field]):
dereferenced_ids.add(i)
if dereferenced_ids:
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
references = self.context.setdefault("references", {})
loop.run_until_complete(
fetch_many(*dereferenced_ids, references=references)
)
data = dereference(data, references)
return super().run_validation(data)
def first_attr(property, attr, aliases=[]):
return {"property": property, "keep": "first", "attr": attr, "aliases": aliases}
def first_val(property, aliases=[]):
return first_attr(property, "@value", aliases=aliases)
def first_id(property, aliases=[]):
return first_attr(property, "@id", aliases=aliases)
def first_obj(property, aliases=[]):
return {"property": property, "keep": "first", "aliases": aliases}
def raw(property, aliases=[]):
return {"property": property, "aliases": aliases}
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
class ActivityPubRenderer(JSONRenderer): def get_ap_renderers():
media_type = "application/activity+json" MEDIA_TYPES = [
("APActivity", "application/activity+json"),
("APLD", "application/ld+json"),
("APJSON", "application/json"),
]
return [
type(name, (JSONRenderer,), {"media_type": media_type})
for name, media_type in MEDIA_TYPES
]
class WebfingerRenderer(JSONRenderer): class WebfingerRenderer(JSONRenderer):
......
...@@ -9,22 +9,24 @@ from rest_framework import serializers ...@@ -9,22 +9,24 @@ from rest_framework import serializers
from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from . import activity, models, utils from . import activity, contexts, jsonld, models, utils
AP_CONTEXT = [ AP_CONTEXT = jsonld.get_default_context()
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{},
]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class LinkSerializer(serializers.Serializer): class LinkSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=["Link"]) type = serializers.ChoiceField(choices=[contexts.AS.Link])
href = serializers.URLField(max_length=500) href = serializers.URLField(max_length=500)
mediaType = serializers.CharField() mediaType = serializers.CharField()
class Meta:
jsonld_mapping = {
"href": jsonld.first_id(contexts.AS.href),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", []) self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", [])
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
...@@ -45,18 +47,52 @@ class LinkSerializer(serializers.Serializer): ...@@ -45,18 +47,52 @@ class LinkSerializer(serializers.Serializer):
) )
class ActorSerializer(serializers.Serializer): class EndpointsSerializer(jsonld.JsonLdSerializer):
sharedInbox = serializers.URLField(max_length=500, required=False)
class Meta:
jsonld_mapping = {"sharedInbox": jsonld.first_id(contexts.AS.sharedInbox)}
class PublicKeySerializer(jsonld.JsonLdSerializer):
publicKeyPem = serializers.CharField(trim_whitespace=False)
class Meta:
jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)}
class ActorSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
outbox = serializers.URLField(max_length=500) outbox = serializers.URLField(max_length=500)
inbox = serializers.URLField(max_length=500) inbox = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=models.TYPE_CHOICES) type = serializers.ChoiceField(
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
)
preferredUsername = serializers.CharField() preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False) manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
name = serializers.CharField(required=False, max_length=200) name = serializers.CharField(required=False, max_length=200)
summary = serializers.CharField(max_length=None, required=False) summary = serializers.CharField(max_length=None, required=False)
followers = serializers.URLField(max_length=500) followers = serializers.URLField(max_length=500)
following = serializers.URLField(max_length=500, required=False, allow_null=True) following = serializers.URLField(max_length=500, required=False, allow_null=True)
publicKey = serializers.JSONField(required=False) publicKey = PublicKeySerializer(required=False)
endpoints = EndpointsSerializer(required=False)
class Meta:
jsonld_mapping = {
"outbox": jsonld.first_id(contexts.AS.outbox),
"inbox": jsonld.first_id(contexts.LDP.inbox),
"following": jsonld.first_id(contexts.AS.following),
"followers": jsonld.first_id(contexts.AS.followers),
"preferredUsername": jsonld.first_val(contexts.AS.preferredUsername),
"summary": jsonld.first_val(contexts.AS.summary),
"name": jsonld.first_val(contexts.AS.name),
"publicKey": jsonld.first_obj(contexts.SEC.publicKey),
"manuallyApprovesFollowers": jsonld.first_val(
contexts.AS.manuallyApprovesFollowers
),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"endpoints": jsonld.first_obj(contexts.AS.endpoints),
}
def to_representation(self, instance): def to_representation(self, instance):
ret = { ret = {
...@@ -115,16 +151,19 @@ class ActorSerializer(serializers.Serializer): ...@@ -115,16 +151,19 @@ class ActorSerializer(serializers.Serializer):
kwargs["manually_approves_followers"] = maf kwargs["manually_approves_followers"] = maf
domain = urllib.parse.urlparse(kwargs["fid"]).netloc domain = urllib.parse.urlparse(kwargs["fid"]).netloc
kwargs["domain"] = models.Domain.objects.get_or_create(pk=domain)[0] kwargs["domain"] = models.Domain.objects.get_or_create(pk=domain)[0]
for endpoint, url in self.initial_data.get("endpoints", {}).items(): for endpoint, url in self.validated_data.get("endpoints", {}).items():
if endpoint == "sharedInbox": if endpoint == "sharedInbox":
kwargs["shared_inbox_url"] = url kwargs["shared_inbox_url"] = url
break break
try: try:
kwargs["public_key"] = self.initial_data["publicKey"]["publicKeyPem"] kwargs["public_key"] = self.validated_data["publicKey"]["publicKeyPem"]
except KeyError: except KeyError:
pass pass
return kwargs return kwargs
def validate_type(self, v):
return v.split("#")[-1]
def build(self): def build(self):
d = self.prepare_missing_fields() d = self.prepare_missing_fields()
return models.Actor(**d) return models.Actor(**d)
...@@ -507,14 +546,26 @@ def get_additional_fields(data): ...@@ -507,14 +546,26 @@ def get_additional_fields(data):
return additional_fields return additional_fields
class PaginatedCollectionSerializer(serializers.Serializer): PAGINATED_COLLECTION_JSONLD_MAPPING = {
type = serializers.ChoiceField(choices=["Collection"]) "totalItems": jsonld.first_val(contexts.AS.totalItems),
"actor": jsonld.first_id(contexts.AS.actor),
"first": jsonld.first_id(contexts.AS.first),
"last": jsonld.first_id(contexts.AS.last),
"partOf": jsonld.first_id(contexts.AS.partOf),
}
class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Collection])
totalItems = serializers.IntegerField(min_value=0) totalItems = serializers.IntegerField(min_value=0)
actor = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500)
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
first = serializers.URLField(max_length=500) first = serializers.URLField(max_length=500)
last = serializers.URLField(max_length=500) last = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
def to_representation(self, conf): def to_representation(self, conf):
paginator = Paginator(conf["items"], conf.get("page_size", 20)) paginator = Paginator(conf["items"], conf.get("page_size", 20))
first = funkwhale_utils.set_query_parameter(conf["id"], page=1) first = funkwhale_utils.set_query_parameter(conf["id"], page=1)
...@@ -536,17 +587,30 @@ class PaginatedCollectionSerializer(serializers.Serializer): ...@@ -536,17 +587,30 @@ class PaginatedCollectionSerializer(serializers.Serializer):
class LibrarySerializer(PaginatedCollectionSerializer): class LibrarySerializer(PaginatedCollectionSerializer):
type = serializers.ChoiceField(choices=["Library"]) type = serializers.ChoiceField(
choices=[contexts.AS.Collection, contexts.FW.Library]
)
name = serializers.CharField() name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False) summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500) followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField( audience = serializers.ChoiceField(
choices=["", None, "https://www.w3.org/ns/activitystreams#Public"], choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False, required=False,
allow_null=True, allow_null=True,
allow_blank=True, allow_blank=True,
) )
class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts(
PAGINATED_COLLECTION_JSONLD_MAPPING,
{
"name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers),
},
)
def to_representation(self, library): def to_representation(self, library):
conf = { conf = {
"id": library.fid, "id": library.fid,
...@@ -559,9 +623,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -559,9 +623,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
} }
r = super().to_representation(conf) r = super().to_representation(conf)
r["audience"] = ( r["audience"] = (
"https://www.w3.org/ns/activitystreams#Public" contexts.AS.Public if library.privacy_level == "everyone" else ""
if library.privacy_level == "everyone"
else ""
) )
r["followers"] = library.followers_url r["followers"] = library.followers_url
return r return r
...@@ -572,6 +634,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -572,6 +634,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
queryset=models.Actor, queryset=models.Actor,
serializer_class=ActorSerializer, serializer_class=ActorSerializer,
) )
privacy = {"": "me", "./": "me", None: "me", contexts.AS.Public: "everyone"}
library, created = music_models.Library.objects.update_or_create( library, created = music_models.Library.objects.update_or_create(
fid=validated_data["id"], fid=validated_data["id"],
actor=actor, actor=actor,
...@@ -580,17 +643,14 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -580,17 +643,14 @@ class LibrarySerializer(PaginatedCollectionSerializer):
"name": validated_data["name"], "name": validated_data["name"],
"description": validated_data["summary"], "description": validated_data["summary"],
"followers_url": validated_data["followers"], "followers_url": validated_data["followers"],
"privacy_level": "everyone" "privacy_level": privacy[validated_data["audience"]],
if validated_data["audience"]
== "https://www.w3.org/ns/activitystreams#Public"
else "me",
}, },
) )
return library return library
class CollectionPageSerializer(serializers.Serializer): class CollectionPageSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=["CollectionPage"]) type = serializers.ChoiceField(choices=[contexts.AS.CollectionPage])
totalItems = serializers.IntegerField(min_value=0) totalItems = serializers.IntegerField(min_value=0)
items = serializers.ListField() items = serializers.ListField()
actor = serializers.URLField(max_length=500) actor = serializers.URLField(max_length=500)
...@@ -601,6 +661,18 @@ class CollectionPageSerializer(serializers.Serializer): ...@@ -601,6 +661,18 @@ class CollectionPageSerializer(serializers.Serializer):
prev = serializers.URLField(max_length=500, required=False) prev = serializers.URLField(max_length=500, required=False)
partOf = serializers.URLField(max_length=500) partOf = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"totalItems": jsonld.first_val(contexts.AS.totalItems),
"items": jsonld.raw(contexts.AS.items),
"actor": jsonld.first_id(contexts.AS.actor),
"first": jsonld.first_id(contexts.AS.first),
"last": jsonld.first_id(contexts.AS.last),
"next": jsonld.first_id(contexts.AS.next),
"prev": jsonld.first_id(contexts.AS.next),
"partOf": jsonld.first_id(contexts.AS.partOf),
}
def validate_items(self, v): def validate_items(self, v):
item_serializer = self.context.get("item_serializer") item_serializer = self.context.get("item_serializer")
if not item_serializer: if not item_serializer:
...@@ -654,7 +726,14 @@ class CollectionPageSerializer(serializers.Serializer): ...@@ -654,7 +726,14 @@ class CollectionPageSerializer(serializers.Serializer):
return d return d
class MusicEntitySerializer(serializers.Serializer): MUSIC_ENTITY_JSONLD_MAPPING = {
"name": jsonld.first_val(contexts.AS.name),
"published": jsonld.first_val(contexts.AS.published),
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
}
class MusicEntitySerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
published = serializers.DateTimeField() published = serializers.DateTimeField()
musicbrainzId = serializers.UUIDField(allow_null=True, required=False) musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
...@@ -662,6 +741,9 @@ class MusicEntitySerializer(serializers.Serializer): ...@@ -662,6 +741,9 @@ class MusicEntitySerializer(serializers.Serializer):
class ArtistSerializer(MusicEntitySerializer): class ArtistSerializer(MusicEntitySerializer):
class Meta:
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
def to_representation(self, instance): def to_representation(self, instance):
d = { d = {
"type": "Artist", "type": "Artist",
...@@ -683,6 +765,16 @@ class AlbumSerializer(MusicEntitySerializer): ...@@ -683,6 +765,16 @@ class AlbumSerializer(MusicEntitySerializer):
allowed_mimetypes=["image/*"], allow_null=True, required=False allowed_mimetypes=["image/*"], allow_null=True, required=False
) )
class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"cover": jsonld.first_obj(contexts.FW.cover),
},
)
def to_representation(self, instance): def to_representation(self, instance):
d = { d = {
"type": "Album", "type": "Album",
...@@ -710,22 +802,6 @@ class AlbumSerializer(MusicEntitySerializer): ...@@ -710,22 +802,6 @@ class AlbumSerializer(MusicEntitySerializer):
d["@context"] = AP_CONTEXT d["@context"] = AP_CONTEXT
return d return d
def get_create_data(self, validated_data):
artist_data = validated_data["artists"][0]
artist = ArtistSerializer(
context={"activity": self.context.get("activity")}
).create(artist_data)
return {
"mbid": validated_data.get("musicbrainzId"),
"fid": validated_data["id"],
"title": validated_data["name"],
"creation_date": validated_data["published"],
"artist": artist,
"release_date": validated_data.get("released"),
"from_activity": self.context.get("activity"),
}
class TrackSerializer(MusicEntitySerializer): class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
...@@ -735,6 +811,19 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -735,6 +811,19 @@ class TrackSerializer(MusicEntitySerializer):
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False)
class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"album": jsonld.first_obj(contexts.FW.album),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license),
"position": jsonld.first_val(contexts.FW.position),
},
)
def to_representation(self, instance): def to_representation(self, instance):
d = { d = {
"type": "Track", "type": "Track",
...@@ -773,8 +862,8 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -773,8 +862,8 @@ class TrackSerializer(MusicEntitySerializer):
return track return track
class UploadSerializer(serializers.Serializer): class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=["Audio"]) type = serializers.ChoiceField(choices=[contexts.AS.Audio])
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
library = serializers.URLField(max_length=500) library = serializers.URLField(max_length=500)
url = LinkSerializer(allowed_mimetypes=["audio/*"]) url = LinkSerializer(allowed_mimetypes=["audio/*"])
...@@ -786,6 +875,18 @@ class UploadSerializer(serializers.Serializer): ...@@ -786,6 +875,18 @@ class UploadSerializer(serializers.Serializer):
track = TrackSerializer(required=True) track = TrackSerializer(required=True)
class Meta:
jsonld_mapping = {
"track": jsonld.first_obj(contexts.FW.track),
"library": jsonld.first_id(contexts.FW.library),
"url": jsonld.first_obj(contexts.AS.url),
"published": jsonld.first_val(contexts.AS.published),
"updated": jsonld.first_val(contexts.AS.updated),
"duration": jsonld.first_val(contexts.AS.duration),
"bitrate": jsonld.first_val(contexts.FW.bitrate),
"size": jsonld.first_val(contexts.FW.size),
}
def validate_url(self, v): def validate_url(self, v):
try: try:
v["href"] v["href"]
...@@ -870,26 +971,6 @@ class UploadSerializer(serializers.Serializer): ...@@ -870,26 +971,6 @@ class UploadSerializer(serializers.Serializer):
return d return d
class CollectionSerializer(serializers.Serializer):
def to_representation(self, conf):
d = {
"id": conf["id"],
"actor": conf["actor"].fid,
"totalItems": len(conf["items"]),
"type": "Collection",
"items": [
conf["item_serializer"](
i, context={"actor": conf["actor"], "include_ap_context": False}
).data
for i in conf["items"]
],
}
if self.context.get("include_ap_context", True):
d["@context"] = AP_CONTEXT
return d
class NodeInfoLinkSerializer(serializers.Serializer): class NodeInfoLinkSerializer(serializers.Serializer):
href = serializers.URLField() href = serializers.URLField()
rel = serializers.URLField() rel = serializers.URLField()
......
...@@ -100,7 +100,7 @@ def retrieve_ap_object( ...@@ -100,7 +100,7 @@ def retrieve_ap_object(
except KeyError: except KeyError:
pass pass
else: else:
if apply_instance_policies and activity.should_reject(id=id, payload=data): if apply_instance_policies and activity.should_reject(fid=id, payload=data):
raise exceptions.BlockedActorOrDomain() raise exceptions.BlockedActorOrDomain()
if not serializer_class: if not serializer_class:
return data return data
......
...@@ -22,7 +22,7 @@ class FederationMixin(object): ...@@ -22,7 +22,7 @@ class FederationMixin(object):
class SharedViewSet(FederationMixin, viewsets.GenericViewSet): class SharedViewSet(FederationMixin, viewsets.GenericViewSet):
permission_classes = [] permission_classes = []
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
@action(methods=["post"], detail=False) @action(methods=["post"], detail=False)
def inbox(self, request, *args, **kwargs): def inbox(self, request, *args, **kwargs):
...@@ -39,7 +39,7 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV ...@@ -39,7 +39,7 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
lookup_field = "preferred_username" lookup_field = "preferred_username"
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
queryset = models.Actor.objects.local().select_related("user") queryset = models.Actor.objects.local().select_related("user")
serializer_class = serializers.ActorSerializer serializer_class = serializers.ActorSerializer
...@@ -74,7 +74,7 @@ class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericVi ...@@ -74,7 +74,7 @@ class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericVi
lookup_field = "uuid" lookup_field = "uuid"
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
# queryset = common_models.Mutation.objects.local().select_related() # queryset = common_models.Mutation.objects.local().select_related()
# serializer_class = serializers.ActorSerializer # serializer_class = serializers.ActorSerializer
...@@ -147,7 +147,7 @@ class MusicLibraryViewSet( ...@@ -147,7 +147,7 @@ class MusicLibraryViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
serializer_class = serializers.LibrarySerializer serializer_class = serializers.LibrarySerializer
queryset = music_models.Library.objects.all().select_related("actor") queryset = music_models.Library.objects.all().select_related("actor")
lookup_field = "uuid" lookup_field = "uuid"
...@@ -202,7 +202,7 @@ class MusicUploadViewSet( ...@@ -202,7 +202,7 @@ class MusicUploadViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related( queryset = music_models.Upload.objects.local().select_related(
"library__actor", "track__artist", "track__album__artist" "library__actor", "track__artist", "track__album__artist"
) )
...@@ -220,7 +220,7 @@ class MusicArtistViewSet( ...@@ -220,7 +220,7 @@ class MusicArtistViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Artist.objects.local() queryset = music_models.Artist.objects.local()
serializer_class = serializers.ArtistSerializer serializer_class = serializers.ArtistSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -231,7 +231,7 @@ class MusicAlbumViewSet( ...@@ -231,7 +231,7 @@ class MusicAlbumViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related("artist") queryset = music_models.Album.objects.local().select_related("artist")
serializer_class = serializers.AlbumSerializer serializer_class = serializers.AlbumSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -242,7 +242,7 @@ class MusicTrackViewSet( ...@@ -242,7 +242,7 @@ class MusicTrackViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related( queryset = music_models.Track.objects.local().select_related(
"album__artist", "artist" "album__artist", "artist"
) )
......
...@@ -64,3 +64,6 @@ django-cleanup==2.1.0 ...@@ -64,3 +64,6 @@ django-cleanup==2.1.0
python-ldap==3.1.0 python-ldap==3.1.0
django-auth-ldap==1.7.0 django-auth-ldap==1.7.0
pydub==0.23.0 pydub==0.23.0
pyld==1.0.4
aiohttp==3.5.4
...@@ -11,3 +11,6 @@ django-debug-toolbar>=1.11,<1.12 ...@@ -11,3 +11,6 @@ django-debug-toolbar>=1.11,<1.12
ipdb==0.11 ipdb==0.11
black black
profiling profiling
asynctest==0.12.2
aioresponses==0.6.0
...@@ -22,6 +22,7 @@ from django.db import connection ...@@ -22,6 +22,7 @@ from django.db import connection
from django.db.migrations.executor import MigrationExecutor from django.db.migrations.executor import MigrationExecutor
from django.db.models import QuerySet from django.db.models import QuerySet
from aioresponses import aioresponses
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework import fields as rest_fields from rest_framework import fields as rest_fields
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
...@@ -30,6 +31,9 @@ from funkwhale_api.activity import record ...@@ -30,6 +31,9 @@ from funkwhale_api.activity import record
from funkwhale_api.users.permissions import HasUserPermission from funkwhale_api.users.permissions import HasUserPermission
pytest_plugins = "aiohttp.pytest_plugin"
class FunkwhaleProvider(internet_provider.Provider): class FunkwhaleProvider(internet_provider.Provider):
""" """
Our own faker data generator, since built-in ones are sometimes Our own faker data generator, since built-in ones are sometimes
...@@ -416,3 +420,9 @@ def migrator(transactional_db): ...@@ -416,3 +420,9 @@ def migrator(transactional_db):
def rsa_small_key(settings): def rsa_small_key(settings):
# smaller size for faster generation, since it's CPU hungry # smaller size for faster generation, since it's CPU hungry
settings.RSA_KEY_SIZE = 512 settings.RSA_KEY_SIZE = 512
@pytest.fixture(autouse=True)
def a_responses():
with aioresponses() as m:
yield m
...@@ -60,7 +60,7 @@ def test_receive_calls_should_reject(factories, now, mocker): ...@@ -60,7 +60,7 @@ def test_receive_calls_should_reject(factories, now, mocker):
copy = activity.receive(activity=a, on_behalf_of=remote_actor) copy = activity.receive(activity=a, on_behalf_of=remote_actor)
should_reject.assert_called_once_with( should_reject.assert_called_once_with(
id=a["id"], actor_id=remote_actor.fid, payload=a fid=a["id"], actor_id=remote_actor.fid, payload=a
) )
assert copy is None assert copy is None
...@@ -68,22 +68,28 @@ def test_receive_calls_should_reject(factories, now, mocker): ...@@ -68,22 +68,28 @@ def test_receive_calls_should_reject(factories, now, mocker):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"params, policy_kwargs, expected", "params, policy_kwargs, expected",
[ [
({"id": "https://ok.test"}, {"target_domain__name": "notok.test"}, False), ({"fid": "https://ok.test"}, {"target_domain__name": "notok.test"}, False),
( (
{"id": "https://ok.test"}, {"fid": "https://ok.test"},
{"target_domain__name": "ok.test", "is_active": False}, {"target_domain__name": "ok.test", "is_active": False},
False, False,
), ),
( (
{"id": "https://ok.test"}, {"fid": "https://ok.test"},
{"target_domain__name": "ok.test", "block_all": False}, {"target_domain__name": "ok.test", "block_all": False},
False, False,
), ),
# id match blocked domain # id match blocked domain
({"id": "http://notok.test"}, {"target_domain__name": "notok.test"}, True), ({"fid": "http://notok.test"}, {"target_domain__name": "notok.test"}, True),
# actor id match blocked domain # actor id match blocked domain
( (
{"id": "http://ok.test", "actor_id": "https://notok.test"}, {"fid": "http://ok.test", "actor_id": "https://notok.test"},
{"target_domain__name": "notok.test"},
True,
),
# actor id match blocked domain
(
{"fid": None, "actor_id": "https://notok.test"},
{"target_domain__name": "notok.test"}, {"target_domain__name": "notok.test"},
True, True,
), ),
...@@ -91,7 +97,7 @@ def test_receive_calls_should_reject(factories, now, mocker): ...@@ -91,7 +97,7 @@ def test_receive_calls_should_reject(factories, now, mocker):
( (
{ {
"payload": {"type": "Library"}, "payload": {"type": "Library"},
"id": "http://ok.test", "fid": "http://ok.test",
"actor_id": "http://notok.test", "actor_id": "http://notok.test",
}, },
{ {
......
import pytest import pytest
from funkwhale_api.federation import authentication, exceptions, keys from funkwhale_api.federation import authentication, exceptions, keys, jsonld
def test_authenticate(factories, mocker, api_request): def test_authenticate(factories, mocker, api_request):
...@@ -10,6 +10,7 @@ def test_authenticate(factories, mocker, api_request): ...@@ -10,6 +10,7 @@ def test_authenticate(factories, mocker, api_request):
mocker.patch( mocker.patch(
"funkwhale_api.federation.actors.get_actor_data", "funkwhale_api.federation.actors.get_actor_data",
return_value={ return_value={
"@context": jsonld.get_default_context(),
"id": actor_url, "id": actor_url,
"type": "Person", "type": "Person",
"outbox": "https://test.com", "outbox": "https://test.com",
...@@ -105,6 +106,7 @@ def test_authenticate_ignore_inactive_policy(factories, api_request, mocker): ...@@ -105,6 +106,7 @@ def test_authenticate_ignore_inactive_policy(factories, api_request, mocker):
mocker.patch( mocker.patch(
"funkwhale_api.federation.actors.get_actor_data", "funkwhale_api.federation.actors.get_actor_data",
return_value={ return_value={
"@context": jsonld.get_default_context(),
"id": actor_url, "id": actor_url,
"type": "Person", "type": "Person",
"outbox": "https://test.com", "outbox": "https://test.com",
...@@ -142,6 +144,7 @@ def test_autenthicate_supports_blind_key_rotation(factories, mocker, api_request ...@@ -142,6 +144,7 @@ def test_autenthicate_supports_blind_key_rotation(factories, mocker, api_request
mocker.patch( mocker.patch(
"funkwhale_api.federation.actors.get_actor_data", "funkwhale_api.federation.actors.get_actor_data",
return_value={ return_value={
"@context": jsonld.get_default_context(),
"id": actor_url, "id": actor_url,
"type": "Person", "type": "Person",
"outbox": "https://test.com", "outbox": "https://test.com",
......
import pytest
from funkwhale_api.federation import contexts
@pytest.mark.parametrize(
"ns, property, expected",
[
("AS", "followers", "https://www.w3.org/ns/activitystreams#followers"),
("AS", "following", "https://www.w3.org/ns/activitystreams#following"),
("SEC", "owner", "https://w3id.org/security#owner"),
("SEC", "publicKey", "https://w3id.org/security#publicKey"),
],
)
def test_context_ns(ns, property, expected):
ns = getattr(contexts, ns)
id = getattr(ns, property)
assert id == expected
def test_raise_on_wrong_attr():
ns = contexts.AS
with pytest.raises(AttributeError):
ns.noop
@pytest.mark.parametrize(
"property, expected",
[("publicKey", "_:publicKey"), ("cover", "_:cover"), ("hello", "_:hello")],
)
def test_noop_context(property, expected):
assert getattr(contexts.NOOP, property) == expected
import pytest
from rest_framework import serializers
from funkwhale_api.federation import contexts
from funkwhale_api.federation import jsonld
def test_expand_no_external_request():
payload = {
"id": "https://noop/federation/actors/demo",
"outbox": "https://noop/federation/actors/demo/outbox",
"inbox": "https://noop/federation/actors/demo/inbox",
"preferredUsername": "demo",
"type": "Person",
"name": "demo",
"followers": "https://noop/federation/actors/demo/followers",
"following": "https://noop/federation/actors/demo/following",
"manuallyApprovesFollowers": False,
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{},
],
"publicKey": {
"owner": "https://noop/federation/actors/demo",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAxPDd/oXx0ClJ2BuBZ937AiERjvoroEpNebg34Cdl6FYsb2Auib8b\nCQjdjLjK/1ag35lmqmsECqtoDYWOo4tGilZJW47TWmXfcvCMH2Sw9FqdOlzpV1RI\nm8kc0Lu1CC2xOTctqIwSH7kDDnS4+S5hSxRdMTeNQNoirncY1CXa9TmJR1lE2HWz\n+B05ewzMrSen3l3fJLQFoI2GVbbjj+tvILKBL1oG5MtYieYqjt2sqtqy/OpWUAC7\nlRERRzd4t5xPBKykWkBCAOh80pvPue5V4s+xUMr7ioKTcm6pq+pNBta5w0hUYIcT\nMefQOnNuR4J0meIqiDLcrglGAmM6AVFwYwIDAQAB\n-----END RSA PUBLIC KEY-----\n", # noqa
"id": "https://noop/federation/actors/demo#main-key",
},
"endpoints": {"sharedInbox": "https://noop/federation/shared/inbox"},
}
expected = {
contexts.AS.endpoints: [
{contexts.AS.sharedInbox: [{"@id": "https://noop/federation/shared/inbox"}]}
],
contexts.AS.followers: [
{"@id": "https://noop/federation/actors/demo/followers"}
],
contexts.AS.following: [
{"@id": "https://noop/federation/actors/demo/following"}
],
"@id": "https://noop/federation/actors/demo",
"http://www.w3.org/ns/ldp#inbox": [
{"@id": "https://noop/federation/actors/demo/inbox"}
],
contexts.AS.manuallyApprovesFollowers: [{"@value": False}],
contexts.AS.name: [{"@value": "demo"}],
contexts.AS.outbox: [{"@id": "https://noop/federation/actors/demo/outbox"}],
contexts.AS.preferredUsername: [{"@value": "demo"}],
contexts.SEC.publicKey: [
{
"@id": "https://noop/federation/actors/demo#main-key",
contexts.SEC.owner: [{"@id": "https://noop/federation/actors/demo"}],
contexts.SEC.publicKeyPem: [
{
"@value": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAxPDd/oXx0ClJ2BuBZ937AiERjvoroEpNebg34Cdl6FYsb2Auib8b\nCQjdjLjK/1ag35lmqmsECqtoDYWOo4tGilZJW47TWmXfcvCMH2Sw9FqdOlzpV1RI\nm8kc0Lu1CC2xOTctqIwSH7kDDnS4+S5hSxRdMTeNQNoirncY1CXa9TmJR1lE2HWz\n+B05ewzMrSen3l3fJLQFoI2GVbbjj+tvILKBL1oG5MtYieYqjt2sqtqy/OpWUAC7\nlRERRzd4t5xPBKykWkBCAOh80pvPue5V4s+xUMr7ioKTcm6pq+pNBta5w0hUYIcT\nMefQOnNuR4J0meIqiDLcrglGAmM6AVFwYwIDAQAB\n-----END RSA PUBLIC KEY-----\n" # noqa
}
],
}
],
"@type": [contexts.AS.Person],
}
doc = jsonld.expand(payload)
assert doc == expected
def test_expand_remote_doc(r_mock):
url = "https://noop/federation/actors/demo"
payload = {
"id": url,
"outbox": "https://noop/federation/actors/demo/outbox",
"inbox": "https://noop/federation/actors/demo/inbox",
"preferredUsername": "demo",
"type": "Person",
"name": "demo",
"followers": "https://noop/federation/actors/demo/followers",
"following": "https://noop/federation/actors/demo/following",
"manuallyApprovesFollowers": False,
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{},
],
"publicKey": {
"owner": "https://noop/federation/actors/demo",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAxPDd/oXx0ClJ2BuBZ937AiERjvoroEpNebg34Cdl6FYsb2Auib8b\nCQjdjLjK/1ag35lmqmsECqtoDYWOo4tGilZJW47TWmXfcvCMH2Sw9FqdOlzpV1RI\nm8kc0Lu1CC2xOTctqIwSH7kDDnS4+S5hSxRdMTeNQNoirncY1CXa9TmJR1lE2HWz\n+B05ewzMrSen3l3fJLQFoI2GVbbjj+tvILKBL1oG5MtYieYqjt2sqtqy/OpWUAC7\nlRERRzd4t5xPBKykWkBCAOh80pvPue5V4s+xUMr7ioKTcm6pq+pNBta5w0hUYIcT\nMefQOnNuR4J0meIqiDLcrglGAmM6AVFwYwIDAQAB\n-----END RSA PUBLIC KEY-----\n", # noqa
"id": "https://noop/federation/actors/demo#main-key",
},
"endpoints": {"sharedInbox": "https://noop/federation/shared/inbox"},
}
r_mock.get(url, json=payload)
expected = {
contexts.AS.endpoints: [
{contexts.AS.sharedInbox: [{"@id": "https://noop/federation/shared/inbox"}]}
],
contexts.AS.followers: [
{"@id": "https://noop/federation/actors/demo/followers"}
],
contexts.AS.following: [
{"@id": "https://noop/federation/actors/demo/following"}
],
"@id": "https://noop/federation/actors/demo",
"http://www.w3.org/ns/ldp#inbox": [
{"@id": "https://noop/federation/actors/demo/inbox"}
],
contexts.AS.manuallyApprovesFollowers: [{"@value": False}],
contexts.AS.name: [{"@value": "demo"}],
contexts.AS.outbox: [{"@id": "https://noop/federation/actors/demo/outbox"}],
contexts.AS.preferredUsername: [{"@value": "demo"}],
contexts.SEC.publicKey: [
{
"@id": "https://noop/federation/actors/demo#main-key",
contexts.SEC.owner: [{"@id": "https://noop/federation/actors/demo"}],
contexts.SEC.publicKeyPem: [
{
"@value": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAxPDd/oXx0ClJ2BuBZ937AiERjvoroEpNebg34Cdl6FYsb2Auib8b\nCQjdjLjK/1ag35lmqmsECqtoDYWOo4tGilZJW47TWmXfcvCMH2Sw9FqdOlzpV1RI\nm8kc0Lu1CC2xOTctqIwSH7kDDnS4+S5hSxRdMTeNQNoirncY1CXa9TmJR1lE2HWz\n+B05ewzMrSen3l3fJLQFoI2GVbbjj+tvILKBL1oG5MtYieYqjt2sqtqy/OpWUAC7\nlRERRzd4t5xPBKykWkBCAOh80pvPue5V4s+xUMr7ioKTcm6pq+pNBta5w0hUYIcT\nMefQOnNuR4J0meIqiDLcrglGAmM6AVFwYwIDAQAB\n-----END RSA PUBLIC KEY-----\n" # noqa
}
],
}
],
"@type": [contexts.AS.Person],
}
doc = jsonld.expand(url)
assert doc == expected
async def test_fetch_many(a_responses):
doc = {
"@context": ["https://www.w3.org/ns/activitystreams", {}],
"id": "https://noop/federation/actors/demo",
"type": "Person",
"followers": "https://noop/federation/actors/demo/followers",
}
followers_doc = {
"@context": ["https://www.w3.org/ns/activitystreams", {}],
"id": "https://noop/federation/actors/demo/followers",
"type": "Collection",
}
a_responses.get(doc["id"], payload=doc)
a_responses.get(followers_doc["id"], payload=followers_doc)
fetched = await jsonld.fetch_many(doc["id"], followers_doc["id"])
assert fetched == {followers_doc["id"]: followers_doc, doc["id"]: doc}
def test_dereference():
followers_doc = {
"@context": ["https://www.w3.org/ns/activitystreams", {}],
"id": "https://noop/federation/actors/demo/followers",
"type": "Collection",
}
actor_doc = {
"@context": ["https://www.w3.org/ns/activitystreams", {}],
"id": "https://noop/federation/actors/demo",
"type": "Person",
"followers": "https://noop/federation/actors/demo/followers",
}
store = {followers_doc["id"]: followers_doc, actor_doc["id"]: actor_doc}
payload = {
"followers": {"@id": followers_doc["id"]},
"actor": [
{"@id": actor_doc["id"], "hello": "world"},
{"somethingElse": [{"@id": actor_doc["id"]}]},
],
}
expected = {
"followers": followers_doc,
"actor": [actor_doc, {"somethingElse": [actor_doc]}],
}
assert jsonld.dereference(payload, store) == expected
def test_prepare_for_serializer():
config = {
"followers": {
"property": contexts.AS.followers,
"keep": "first",
"attr": "@id",
},
"name": {"property": contexts.AS.name, "keep": "first", "attr": "@value"},
"keys": {"property": contexts.SEC.publicKey, "type": "raw"},
}
payload = {
"@id": "https://noop/federation/actors/demo",
"@type": [contexts.AS.Person],
contexts.AS.followers: [
{"@id": "https://noop/federation/actors/demo/followers"}
],
contexts.AS.name: [{"@value": "demo"}],
contexts.SEC.publicKey: [
{"@id": "https://noop/federation/actors/demo#main-key1"},
{"@id": "https://noop/federation/actors/demo#main-key2"},
],
}
expected = {
"id": "https://noop/federation/actors/demo",
"type": contexts.AS.Person,
"followers": "https://noop/federation/actors/demo/followers",
"name": "demo",
"keys": [
{"@id": "https://noop/federation/actors/demo#main-key1"},
{"@id": "https://noop/federation/actors/demo#main-key2"},
],
}
assert jsonld.prepare_for_serializer(payload, config) == expected
def test_prepare_for_serializer_fallback():
config = {
"name": {"property": contexts.AS.name, "keep": "first", "attr": "@value"},
"album": {"property": contexts.FW.Album, "keep": "first"},
"noop_album": {"property": contexts.NOOP.Album, "keep": "first"},
}
fallbacks = {"album": ["noop_album"]}
payload = {
"@id": "https://noop/federation/actors/demo",
"@type": [contexts.AS.Person],
contexts.AS.name: [{"@value": "demo"}],
contexts.NOOP.Album: [{"@id": "https://noop/federation/album/demo"}],
}
expected = {
"id": "https://noop/federation/actors/demo",
"type": contexts.AS.Person,
"name": "demo",
"album": {"@id": "https://noop/federation/album/demo"},
"noop_album": {"@id": "https://noop/federation/album/demo"},
}
assert (
jsonld.prepare_for_serializer(payload, config, fallbacks=fallbacks) == expected
)
def test_jsonld_serializer_fallback():
class TestSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField()
type = serializers.CharField()
name = serializers.CharField()
username = serializers.CharField()
total = serializers.IntegerField()
class Meta:
jsonld_fallbacks = {"total": ["total_fallback"]}
jsonld_mapping = {
"name": {
"property": contexts.AS.name,
"keep": "first",
"attr": "@value",
},
"username": {
"property": contexts.AS.preferredUsername,
"keep": "first",
"attr": "@value",
},
"total": {
"property": contexts.AS.totalItems,
"keep": "first",
"attr": "@value",
},
"total_fallback": {
"property": contexts.NOOP.count,
"keep": "first",
"attr": "@value",
},
}
payload = {
"@context": ["https://www.w3.org/ns/activitystreams", {}],
"id": "https://noop.url/federation/actors/demo",
"type": "Person",
"name": "Hello",
"preferredUsername": "World",
"count": 42,
}
serializer = TestSerializer(data=payload)
assert serializer.is_valid(raise_exception=True)
assert serializer.validated_data == {
"type": contexts.AS.Person,
"id": payload["id"],
"name": payload["name"],
"username": payload["preferredUsername"],
"total": 42,
}
def test_jsonld_serializer_dereference(a_responses):
class TestSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField()
type = serializers.CharField()
followers = serializers.JSONField()
class Meta:
jsonld_mapping = {
"followers": {"property": contexts.AS.followers, "dereference": True}
}
payload = {
"@context": ["https://www.w3.org/ns/activitystreams", {}],
"id": "https://noop.url/federation/actors/demo",
"type": "Person",
"followers": "https://noop.url/federation/actors/demo/followers",
}
followers_doc = {
"@context": ["https://www.w3.org/ns/activitystreams", {}],
"id": "https://noop.url/federation/actors/demo/followers",
"type": "Collection",
}
a_responses.get(followers_doc["id"], payload=followers_doc)
serializer = TestSerializer(data=payload)
assert serializer.is_valid(raise_exception=True)
assert serializer.validated_data == {
"type": contexts.AS.Person,
"id": payload["id"],
"followers": [followers_doc],
}
@pytest.mark.parametrize(
"doc, ctx, expected",
[
(
{"@context": [{}], "hello": "world"},
"http://test",
{"@context": [{}, "http://test"], "hello": "world"},
),
(
{"@context": {"key": "value"}, "hello": "world"},
"http://test",
{"@context": [{"key": "value"}, "http://test"], "hello": "world"},
),
(
{"@context": "http://as", "hello": "world"},
"http://test",
{"@context": ["http://as", "http://test"], "hello": "world"},
),
],
)
def test_insert_context(doc, ctx, expected):
jsonld.insert_context(ctx, doc)
assert doc == expected
import pytest import pytest
from funkwhale_api.federation import routes, serializers from funkwhale_api.federation import jsonld, routes, serializers
@pytest.mark.parametrize( @pytest.mark.parametrize(
...@@ -190,6 +190,7 @@ def test_inbox_create_audio(factories, mocker): ...@@ -190,6 +190,7 @@ def test_inbox_create_audio(factories, mocker):
activity = factories["federation.Activity"]() activity = factories["federation.Activity"]()
upload = factories["music.Upload"](bitrate=42, duration=55) upload = factories["music.Upload"](bitrate=42, duration=55)
payload = { payload = {
"@context": jsonld.get_default_context(),
"type": "Create", "type": "Create",
"actor": upload.library.actor.fid, "actor": upload.library.actor.fid,
"object": serializers.UploadSerializer(upload).data, "object": serializers.UploadSerializer(upload).data,
......
This diff is collapsed.
...@@ -93,6 +93,35 @@ def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_act ...@@ -93,6 +93,35 @@ def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_act
) )
def test_local_actor_inbox_post_receive(
factories, api_client, mocker, authenticated_actor
):
payload = {
"to": [
"https://test.server/federation/music/libraries/956af6c9-1eb9-4117-8d17-b15e7b34afeb/followers"
],
"type": "Create",
"actor": authenticated_actor.fid,
"object": {
"id": "https://test.server/federation/music/uploads/fe564a47-b1d4-4596-bf96-008ccf407672",
"type": "Audio",
},
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{},
],
}
user = factories["users.User"](with_actor=True)
url = reverse(
"federation:actors-inbox",
kwargs={"preferred_username": user.actor.preferred_username},
)
response = api_client.post(url, payload, format="json")
assert response.status_code == 200
def test_shared_inbox_post(factories, api_client, mocker, authenticated_actor): def test_shared_inbox_post(factories, api_client, mocker, authenticated_actor):
patched_receive = mocker.patch("funkwhale_api.federation.activity.receive") patched_receive = mocker.patch("funkwhale_api.federation.activity.receive")
url = reverse("federation:shared-inbox") url = reverse("federation:shared-inbox")
......
...@@ -8,6 +8,7 @@ from django.core.paginator import Paginator ...@@ -8,6 +8,7 @@ from django.core.paginator import Paginator
from django.utils import timezone from django.utils import timezone
from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.federation import jsonld
from funkwhale_api.music import licenses, metadata, signals, tasks from funkwhale_api.music import licenses, metadata, signals, tasks
DATA_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.dirname(os.path.abspath(__file__))
...@@ -400,6 +401,7 @@ def test_federation_audio_track_to_metadata(now): ...@@ -400,6 +401,7 @@ def test_federation_audio_track_to_metadata(now):
published = now published = now
released = now.date() released = now.date()
payload = { payload = {
"@context": jsonld.get_default_context(),
"type": "Track", "type": "Track",
"id": "http://hello.track", "id": "http://hello.track",
"musicbrainzId": str(uuid.uuid4()), "musicbrainzId": str(uuid.uuid4()),
...@@ -425,6 +427,11 @@ def test_federation_audio_track_to_metadata(now): ...@@ -425,6 +427,11 @@ def test_federation_audio_track_to_metadata(now):
"musicbrainzId": str(uuid.uuid4()), "musicbrainzId": str(uuid.uuid4()),
} }
], ],
"cover": {
"type": "Link",
"href": "http://cover.test",
"mediaType": "image/png",
},
}, },
"artists": [ "artists": [
{ {
...@@ -464,6 +471,10 @@ def test_federation_audio_track_to_metadata(now): ...@@ -464,6 +471,10 @@ def test_federation_audio_track_to_metadata(now):
"published" "published"
], ],
"album_fdate": serializer.validated_data["album"]["published"], "album_fdate": serializer.validated_data["album"]["published"],
"cover_data": {
"mimetype": serializer.validated_data["album"]["cover"]["mediaType"],
"url": serializer.validated_data["album"]["cover"]["href"],
},
} }
result = tasks.federation_audio_track_to_metadata(serializer.validated_data) result = tasks.federation_audio_track_to_metadata(serializer.validated_data)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment