Merge branch 'federation-inbox' into 'develop'
Federation inbox See merge request funkwhale/funkwhale!121
This commit is contained in:
commit
7bb15a3aa1
6
.env.dev
6
.env.dev
|
@ -1,3 +1,9 @@
|
|||
API_AUTHENTICATION_REQUIRED=True
|
||||
RAVEN_ENABLED=false
|
||||
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
|
||||
DJANGO_ALLOWED_HOSTS=localhost,nginx
|
||||
DJANGO_SETTINGS_MODULE=config.settings.local
|
||||
DJANGO_SECRET_KEY=dev
|
||||
C_FORCE_ROOT=true
|
||||
FUNKWHALE_URL=http://localhost
|
||||
PYTHONDONTWRITEBYTECODE=true
|
||||
|
|
|
@ -13,6 +13,7 @@ stages:
|
|||
test_api:
|
||||
services:
|
||||
- postgres:9.4
|
||||
- redis:3
|
||||
stage: test
|
||||
image: funkwhale/funkwhale:latest
|
||||
cache:
|
||||
|
@ -24,6 +25,7 @@ test_api:
|
|||
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
FUNKWHALE_URL: "https://funkwhale.ci"
|
||||
CACHEOPS_ENABLED: "false"
|
||||
DJANGO_SETTINGS_MODULE: config.settings.local
|
||||
|
||||
before_script:
|
||||
- cd api
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
if [ $1 = "pytest" ]; then
|
||||
# let pytest.ini handle it
|
||||
unset DJANGO_SETTINGS_MODULE
|
||||
fi
|
||||
exec "$@"
|
||||
|
|
|
@ -344,7 +344,12 @@ REST_FRAMEWORK = {
|
|||
),
|
||||
'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
|
||||
'PAGE_SIZE': 25,
|
||||
|
||||
'DEFAULT_PARSER_CLASSES': (
|
||||
'rest_framework.parsers.JSONParser',
|
||||
'rest_framework.parsers.FormParser',
|
||||
'rest_framework.parsers.MultiPartParser',
|
||||
'funkwhale_api.federation.parsers.ActivityParser',
|
||||
),
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
|
||||
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
|
||||
|
@ -396,6 +401,9 @@ PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
|
|||
|
||||
ACCOUNT_USERNAME_BLACKLIST = [
|
||||
'funkwhale',
|
||||
'library',
|
||||
'test',
|
||||
'status',
|
||||
'root',
|
||||
'admin',
|
||||
'owner',
|
||||
|
|
|
@ -72,6 +72,10 @@ LOGGING = {
|
|||
'handlers':['console'],
|
||||
'propagate': True,
|
||||
'level':'DEBUG',
|
||||
}
|
||||
},
|
||||
'': {
|
||||
'level': 'DEBUG',
|
||||
'handlers': ['console'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
from .common import * # noqa
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
|
||||
|
||||
# Mail settings
|
||||
# ------------------------------------------------------------------------------
|
||||
EMAIL_HOST = 'localhost'
|
||||
EMAIL_PORT = 1025
|
||||
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
|
||||
default='django.core.mail.backends.console.EmailBackend')
|
||||
|
||||
# CACHING
|
||||
# ------------------------------------------------------------------------------
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': ''
|
||||
}
|
||||
}
|
||||
|
||||
CELERY_BROKER_URL = 'memory://'
|
||||
|
||||
########## CELERY
|
||||
# In development, all tasks will be executed locally by blocking until the task returns
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
########## END CELERY
|
||||
|
||||
# Your local stuff: Below this line define 3rd party library settings
|
||||
API_AUTHENTICATION_REQUIRED = False
|
||||
CACHEOPS_ENABLED = False
|
|
@ -0,0 +1,85 @@
|
|||
import logging
|
||||
import json
|
||||
import requests
|
||||
import requests_http_signature
|
||||
|
||||
from . import signing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVITY_TYPES = [
|
||||
'Accept',
|
||||
'Add',
|
||||
'Announce',
|
||||
'Arrive',
|
||||
'Block',
|
||||
'Create',
|
||||
'Delete',
|
||||
'Dislike',
|
||||
'Flag',
|
||||
'Follow',
|
||||
'Ignore',
|
||||
'Invite',
|
||||
'Join',
|
||||
'Leave',
|
||||
'Like',
|
||||
'Listen',
|
||||
'Move',
|
||||
'Offer',
|
||||
'Question',
|
||||
'Reject',
|
||||
'Read',
|
||||
'Remove',
|
||||
'TentativeReject',
|
||||
'TentativeAccept',
|
||||
'Travel',
|
||||
'Undo',
|
||||
'Update',
|
||||
'View',
|
||||
]
|
||||
|
||||
|
||||
OBJECT_TYPES = [
|
||||
'Article',
|
||||
'Audio',
|
||||
'Document',
|
||||
'Event',
|
||||
'Image',
|
||||
'Note',
|
||||
'Page',
|
||||
'Place',
|
||||
'Profile',
|
||||
'Relationship',
|
||||
'Tombstone',
|
||||
'Video',
|
||||
]
|
||||
|
||||
def deliver(activity, on_behalf_of, to=[]):
|
||||
from . import actors
|
||||
logger.info('Preparing activity delivery to %s', to)
|
||||
auth = requests_http_signature.HTTPSignatureAuth(
|
||||
use_auth_header=False,
|
||||
headers=[
|
||||
'(request-target)',
|
||||
'user-agent',
|
||||
'host',
|
||||
'date',
|
||||
'content-type',],
|
||||
algorithm='rsa-sha256',
|
||||
key=on_behalf_of.private_key.encode('utf-8'),
|
||||
key_id=on_behalf_of.private_key_id,
|
||||
)
|
||||
for url in to:
|
||||
recipient_actor = actors.get_actor(url)
|
||||
logger.debug('delivering to %s', recipient_actor.inbox_url)
|
||||
logger.debug('activity content: %s', json.dumps(activity))
|
||||
response = requests.post(
|
||||
auth=auth,
|
||||
json=activity,
|
||||
url=recipient_actor.inbox_url,
|
||||
headers={
|
||||
'Content-Type': 'application/activity+json'
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug('Remote answered with %s', response.status_code)
|
|
@ -0,0 +1,222 @@
|
|||
import logging
|
||||
import requests
|
||||
import xml
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from . import activity
|
||||
from . import factories
|
||||
from . import models
|
||||
from . import serializers
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def remove_tags(text):
|
||||
logger.debug('Removing tags from %s', text)
|
||||
return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext())
|
||||
|
||||
|
||||
def get_actor_data(actor_url):
|
||||
response = requests.get(
|
||||
actor_url,
|
||||
headers={
|
||||
'Accept': 'application/activity+json',
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
return response.json()
|
||||
except:
|
||||
raise ValueError(
|
||||
'Invalid actor payload: {}'.format(response.text))
|
||||
|
||||
def get_actor(actor_url):
|
||||
data = get_actor_data(actor_url)
|
||||
serializer = serializers.ActorSerializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
return serializer.build()
|
||||
|
||||
|
||||
class SystemActor(object):
|
||||
additional_attributes = {}
|
||||
|
||||
def get_actor_instance(self):
|
||||
a = models.Actor(
|
||||
**self.get_instance_argument(
|
||||
self.id,
|
||||
name=self.name,
|
||||
summary=self.summary,
|
||||
**self.additional_attributes
|
||||
)
|
||||
)
|
||||
a.pk = self.id
|
||||
return a
|
||||
|
||||
def get_instance_argument(self, id, name, summary, **kwargs):
|
||||
preferences = global_preferences_registry.manager()
|
||||
p = {
|
||||
'preferred_username': id,
|
||||
'domain': settings.FEDERATION_HOSTNAME,
|
||||
'type': 'Person',
|
||||
'name': name.format(host=settings.FEDERATION_HOSTNAME),
|
||||
'manually_approves_followers': True,
|
||||
'url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-detail',
|
||||
kwargs={'actor': id})),
|
||||
'shared_inbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-inbox',
|
||||
kwargs={'actor': id})),
|
||||
'inbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-inbox',
|
||||
kwargs={'actor': id})),
|
||||
'outbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-outbox',
|
||||
kwargs={'actor': id})),
|
||||
'public_key': preferences['federation__public_key'],
|
||||
'private_key': preferences['federation__private_key'],
|
||||
'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
|
||||
}
|
||||
p.update(kwargs)
|
||||
return p
|
||||
|
||||
def get_inbox(self, data, actor=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def post_inbox(self, data, actor=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_outbox(self, data, actor=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def post_outbox(self, data, actor=None):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LibraryActor(SystemActor):
|
||||
id = 'library'
|
||||
name = '{host}\'s library'
|
||||
summary = 'Bot account to federate with {host}\'s library'
|
||||
additional_attributes = {
|
||||
'manually_approves_followers': True
|
||||
}
|
||||
|
||||
|
||||
class TestActor(SystemActor):
|
||||
id = 'test'
|
||||
name = '{host}\'s test account'
|
||||
summary = (
|
||||
'Bot account to test federation with {host}. '
|
||||
'Send me /ping and I\'ll answer you.'
|
||||
)
|
||||
additional_attributes = {
|
||||
'manually_approves_followers': False
|
||||
}
|
||||
|
||||
def get_outbox(self, data, actor=None):
|
||||
return {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{}
|
||||
],
|
||||
"id": utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-outbox',
|
||||
kwargs={'actor': self.id})),
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": 0,
|
||||
"orderedItems": []
|
||||
}
|
||||
|
||||
def post_inbox(self, data, actor=None):
|
||||
if actor is None:
|
||||
raise PermissionDenied('Actor not authenticated')
|
||||
|
||||
serializer = serializers.ActivitySerializer(
|
||||
data=data, context={'actor': actor})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
ac = serializer.validated_data
|
||||
logger.info('Received activity on %s inbox', self.id)
|
||||
if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
|
||||
# we received a toot \o/
|
||||
command = self.parse_command(ac['object']['content'])
|
||||
logger.debug('Parsed command: %s', command)
|
||||
if command == 'ping':
|
||||
self.handle_ping(ac, actor)
|
||||
|
||||
def parse_command(self, message):
|
||||
"""
|
||||
Remove any links or fancy markup to extract /command from
|
||||
a note message.
|
||||
"""
|
||||
raw = remove_tags(message)
|
||||
try:
|
||||
return raw.split('/')[1]
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
def handle_ping(self, ac, sender):
|
||||
now = timezone.now()
|
||||
test_actor = self.get_actor_instance()
|
||||
reply_url = 'https://{}/activities/note/{}'.format(
|
||||
settings.FEDERATION_HOSTNAME, now.timestamp()
|
||||
)
|
||||
reply_content = '{} Pong!'.format(
|
||||
sender.mention_username
|
||||
)
|
||||
reply_activity = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{}
|
||||
],
|
||||
'type': 'Create',
|
||||
'actor': test_actor.url,
|
||||
'id': '{}/activity'.format(reply_url),
|
||||
'published': now.isoformat(),
|
||||
'to': ac['actor'],
|
||||
'cc': [],
|
||||
'object': factories.NoteFactory(
|
||||
content='Pong!',
|
||||
summary=None,
|
||||
published=now.isoformat(),
|
||||
id=reply_url,
|
||||
inReplyTo=ac['object']['id'],
|
||||
sensitive=False,
|
||||
url=reply_url,
|
||||
to=[ac['actor']],
|
||||
attributedTo=test_actor.url,
|
||||
cc=[],
|
||||
attachment=[],
|
||||
tag=[
|
||||
{
|
||||
"type": "Mention",
|
||||
"href": ac['actor'],
|
||||
"name": sender.mention_username
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
activity.deliver(
|
||||
reply_activity,
|
||||
to=[ac['actor']],
|
||||
on_behalf_of=test_actor)
|
||||
|
||||
SYSTEM_ACTORS = {
|
||||
'library': LibraryActor(),
|
||||
'test': TestActor(),
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import cryptography
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
|
||||
from . import actors
|
||||
from . import keys
|
||||
from . import serializers
|
||||
from . import signing
|
||||
from . import utils
|
||||
|
||||
|
||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||
def authenticate_actor(self, request):
|
||||
headers = utils.clean_wsgi_headers(request.META)
|
||||
try:
|
||||
signature = headers['Signature']
|
||||
key_id = keys.get_key_id_from_signature_header(signature)
|
||||
except KeyError:
|
||||
return
|
||||
except ValueError as e:
|
||||
raise exceptions.AuthenticationFailed(str(e))
|
||||
|
||||
try:
|
||||
actor_data = actors.get_actor_data(key_id)
|
||||
except Exception as e:
|
||||
raise exceptions.AuthenticationFailed(str(e))
|
||||
|
||||
try:
|
||||
public_key = actor_data['publicKey']['publicKeyPem']
|
||||
except KeyError:
|
||||
raise exceptions.AuthenticationFailed('No public key found')
|
||||
|
||||
serializer = serializers.ActorSerializer(data=actor_data)
|
||||
if not serializer.is_valid():
|
||||
raise exceptions.AuthenticationFailed('Invalid actor payload: {}'.format(serializer.errors))
|
||||
|
||||
try:
|
||||
signing.verify_django(request, public_key.encode('utf-8'))
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
raise exceptions.AuthenticationFailed('Invalid signature')
|
||||
|
||||
return serializer.build()
|
||||
|
||||
def authenticate(self, request):
|
||||
setattr(request, 'actor', None)
|
||||
actor = self.authenticate_actor(request)
|
||||
user = AnonymousUser()
|
||||
setattr(request, 'actor', actor)
|
||||
return (user, None)
|
|
@ -2,3 +2,7 @@
|
|||
|
||||
class MalformedPayload(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class MissingSignature(KeyError):
|
||||
pass
|
||||
|
|
|
@ -2,9 +2,12 @@ import factory
|
|||
import requests
|
||||
import requests_http_signature
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.factories import registry
|
||||
|
||||
from . import keys
|
||||
from . import models
|
||||
|
||||
|
||||
registry.register(keys.get_key_pair, name='federation.KeyPair')
|
||||
|
@ -15,7 +18,13 @@ class SignatureAuthFactory(factory.Factory):
|
|||
algorithm = 'rsa-sha256'
|
||||
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
|
||||
key_id = factory.Faker('url')
|
||||
|
||||
use_auth_header = False
|
||||
headers = [
|
||||
'(request-target)',
|
||||
'user-agent',
|
||||
'host',
|
||||
'date',
|
||||
'content-type',]
|
||||
class Meta:
|
||||
model = requests_http_signature.HTTPSignatureAuth
|
||||
|
||||
|
@ -28,3 +37,55 @@ class SignedRequestFactory(factory.Factory):
|
|||
|
||||
class Meta:
|
||||
model = requests.Request
|
||||
|
||||
@factory.post_generation
|
||||
def headers(self, create, extracted, **kwargs):
|
||||
default_headers = {
|
||||
'User-Agent': 'Test',
|
||||
'Host': 'test.host',
|
||||
'Date': 'Right now',
|
||||
'Content-Type': 'application/activity+json'
|
||||
}
|
||||
if extracted:
|
||||
default_headers.update(extracted)
|
||||
self.headers.update(default_headers)
|
||||
|
||||
|
||||
@registry.register
|
||||
class ActorFactory(factory.DjangoModelFactory):
|
||||
|
||||
public_key = None
|
||||
private_key = None
|
||||
preferred_username = factory.Faker('user_name')
|
||||
summary = factory.Faker('paragraph')
|
||||
domain = factory.Faker('domain_name')
|
||||
url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username))
|
||||
inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username))
|
||||
outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username))
|
||||
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
|
||||
@classmethod
|
||||
def _generate(cls, create, attrs):
|
||||
has_public = attrs.get('public_key') is not None
|
||||
has_private = attrs.get('private_key') is not None
|
||||
if not has_public and not has_private:
|
||||
private, public = keys.get_key_pair()
|
||||
attrs['private_key'] = private.decode('utf-8')
|
||||
attrs['public_key'] = public.decode('utf-8')
|
||||
return super()._generate(create, attrs)
|
||||
|
||||
|
||||
@registry.register(name='federation.Note')
|
||||
class NoteFactory(factory.Factory):
|
||||
type = 'Note'
|
||||
id = factory.Faker('url')
|
||||
published = factory.LazyFunction(
|
||||
lambda: timezone.now().isoformat()
|
||||
)
|
||||
inReplyTo = None
|
||||
content = factory.Faker('sentence')
|
||||
|
||||
class Meta:
|
||||
model = dict
|
||||
|
|
|
@ -2,10 +2,14 @@ from cryptography.hazmat.primitives import serialization as crypto_serialization
|
|||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||
|
||||
import re
|
||||
import requests
|
||||
import urllib.parse
|
||||
|
||||
from . import exceptions
|
||||
|
||||
KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"')
|
||||
|
||||
|
||||
def get_key_pair(size=2048):
|
||||
key = rsa.generate_private_key(
|
||||
|
@ -25,19 +29,21 @@ def get_key_pair(size=2048):
|
|||
return private_key, public_key
|
||||
|
||||
|
||||
def get_public_key(actor_url):
|
||||
"""
|
||||
Given an actor_url, request it and extract publicKey data from
|
||||
the response payload.
|
||||
"""
|
||||
response = requests.get(actor_url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
def get_key_id_from_signature_header(header_string):
|
||||
parts = header_string.split(',')
|
||||
try:
|
||||
return {
|
||||
'public_key_pem': payload['publicKey']['publicKeyPem'],
|
||||
'id': payload['publicKey']['id'],
|
||||
'owner': payload['publicKey']['owner'],
|
||||
}
|
||||
except KeyError:
|
||||
raise exceptions.MalformedPayload(str(payload))
|
||||
raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
|
||||
except IndexError:
|
||||
raise ValueError('Missing key id')
|
||||
|
||||
match = KEY_ID_REGEX.match(raw_key_id)
|
||||
if not match:
|
||||
raise ValueError('Invalid key id')
|
||||
|
||||
key_id = match.groups()[0]
|
||||
url = urllib.parse.urlparse(key_id)
|
||||
if not url.scheme or not url.netloc:
|
||||
raise ValueError('Invalid url')
|
||||
if url.scheme not in ['http', 'https']:
|
||||
raise ValueError('Invalid shceme')
|
||||
return key_id
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 2.0.3 on 2018-03-31 13:43
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Actor',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField(db_index=True, max_length=500, unique=True)),
|
||||
('outbox_url', models.URLField(max_length=500)),
|
||||
('inbox_url', models.URLField(max_length=500)),
|
||||
('following_url', models.URLField(blank=True, max_length=500, null=True)),
|
||||
('followers_url', models.URLField(blank=True, max_length=500, null=True)),
|
||||
('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)),
|
||||
('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)),
|
||||
('name', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('domain', models.CharField(max_length=1000)),
|
||||
('summary', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('preferred_username', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('public_key', models.CharField(blank=True, max_length=5000, null=True)),
|
||||
('private_key', models.CharField(blank=True, max_length=5000, null=True)),
|
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('manually_approves_followers', models.NullBooleanField(default=None)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,59 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
TYPE_CHOICES = [
|
||||
('Person', 'Person'),
|
||||
('Application', 'Application'),
|
||||
('Group', 'Group'),
|
||||
('Organization', 'Organization'),
|
||||
('Service', 'Service'),
|
||||
]
|
||||
|
||||
|
||||
class Actor(models.Model):
|
||||
url = models.URLField(unique=True, max_length=500, db_index=True)
|
||||
outbox_url = models.URLField(max_length=500)
|
||||
inbox_url = models.URLField(max_length=500)
|
||||
following_url = models.URLField(max_length=500, null=True, blank=True)
|
||||
followers_url = models.URLField(max_length=500, null=True, blank=True)
|
||||
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
|
||||
type = models.CharField(
|
||||
choices=TYPE_CHOICES, default='Person', max_length=25)
|
||||
name = models.CharField(max_length=200, null=True, blank=True)
|
||||
domain = models.CharField(max_length=1000)
|
||||
summary = models.CharField(max_length=500, null=True, blank=True)
|
||||
preferred_username = models.CharField(
|
||||
max_length=200, null=True, blank=True)
|
||||
public_key = models.CharField(max_length=5000, null=True, blank=True)
|
||||
private_key = models.CharField(max_length=5000, null=True, blank=True)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
last_fetch_date = models.DateTimeField(
|
||||
default=timezone.now)
|
||||
manually_approves_followers = models.NullBooleanField(default=None)
|
||||
|
||||
@property
|
||||
def webfinger_subject(self):
|
||||
return '{}@{}'.format(
|
||||
self.preferred_username,
|
||||
settings.FEDERATION_HOSTNAME,
|
||||
)
|
||||
|
||||
@property
|
||||
def private_key_id(self):
|
||||
return '{}#main-key'.format(self.url)
|
||||
|
||||
@property
|
||||
def mention_username(self):
|
||||
return '@{}@{}'.format(self.preferred_username, self.domain)
|
||||
|
||||
def save(self, **kwargs):
|
||||
lowercase_fields = [
|
||||
'domain',
|
||||
]
|
||||
for field in lowercase_fields:
|
||||
v = getattr(self, field, None)
|
||||
if v:
|
||||
setattr(self, field, v.lower())
|
||||
|
||||
super().save(**kwargs)
|
|
@ -0,0 +1,5 @@
|
|||
from rest_framework import parsers
|
||||
|
||||
|
||||
class ActivityParser(parsers.JSONParser):
|
||||
media_type = 'application/activity+json'
|
|
@ -1,38 +1,175 @@
|
|||
import urllib.parse
|
||||
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from . import activity
|
||||
from . import models
|
||||
from . import utils
|
||||
|
||||
|
||||
def repr_instance_actor():
|
||||
"""
|
||||
We do not use a serializer here, since it's pretty static
|
||||
"""
|
||||
actor_url = utils.full_url(reverse('federation:instance-actor'))
|
||||
preferences = global_preferences_registry.manager()
|
||||
public_key = preferences['federation__public_key']
|
||||
class ActorSerializer(serializers.ModelSerializer):
|
||||
# left maps to activitypub fields, right to our internal models
|
||||
id = serializers.URLField(source='url')
|
||||
outbox = serializers.URLField(source='outbox_url')
|
||||
inbox = serializers.URLField(source='inbox_url')
|
||||
following = serializers.URLField(source='following_url', required=False)
|
||||
followers = serializers.URLField(source='followers_url', required=False)
|
||||
preferredUsername = serializers.CharField(
|
||||
source='preferred_username', required=False)
|
||||
publicKey = serializers.JSONField(source='public_key', required=False)
|
||||
manuallyApprovesFollowers = serializers.NullBooleanField(
|
||||
source='manually_approves_followers', required=False)
|
||||
summary = serializers.CharField(max_length=None, required=False)
|
||||
|
||||
return {
|
||||
'@context': [
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
fields = [
|
||||
'id',
|
||||
'type',
|
||||
'name',
|
||||
'summary',
|
||||
'preferredUsername',
|
||||
'publicKey',
|
||||
'inbox',
|
||||
'outbox',
|
||||
'following',
|
||||
'followers',
|
||||
'manuallyApprovesFollowers',
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret['@context'] = [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': utils.full_url(reverse('federation:instance-actor')),
|
||||
'type': 'Person',
|
||||
'inbox': utils.full_url(reverse('federation:instance-inbox')),
|
||||
'outbox': utils.full_url(reverse('federation:instance-outbox')),
|
||||
'preferredUsername': 'service',
|
||||
'name': 'Service Bot - {}'.format(settings.FEDERATION_HOSTNAME),
|
||||
'summary': 'Bot account for federating with {}'.format(
|
||||
settings.FEDERATION_HOSTNAME
|
||||
),
|
||||
'publicKey': {
|
||||
'id': '{}#main-key'.format(actor_url),
|
||||
'owner': actor_url,
|
||||
'publicKeyPem': public_key
|
||||
},
|
||||
]
|
||||
if instance.public_key:
|
||||
ret['publicKey'] = {
|
||||
'owner': instance.url,
|
||||
'publicKeyPem': instance.public_key,
|
||||
'id': '{}#main-key'.format(instance.url)
|
||||
}
|
||||
ret['endpoints'] = {}
|
||||
if instance.shared_inbox_url:
|
||||
ret['endpoints']['sharedInbox'] = instance.shared_inbox_url
|
||||
return ret
|
||||
|
||||
}
|
||||
def prepare_missing_fields(self):
|
||||
kwargs = {}
|
||||
domain = urllib.parse.urlparse(self.validated_data['url']).netloc
|
||||
kwargs['domain'] = domain
|
||||
for endpoint, url in self.initial_data.get('endpoints', {}).items():
|
||||
if endpoint == 'sharedInbox':
|
||||
kwargs['shared_inbox_url'] = url
|
||||
break
|
||||
try:
|
||||
kwargs['public_key'] = self.initial_data['publicKey']['publicKeyPem']
|
||||
except KeyError:
|
||||
pass
|
||||
return kwargs
|
||||
|
||||
def build(self):
|
||||
d = self.validated_data.copy()
|
||||
d.update(self.prepare_missing_fields())
|
||||
return self.Meta.model(**d)
|
||||
|
||||
def save(self, **kwargs):
|
||||
kwargs.update(self.prepare_missing_fields())
|
||||
return super().save(**kwargs)
|
||||
|
||||
def validate_summary(self, value):
|
||||
if value:
|
||||
return value[:500]
|
||||
|
||||
|
||||
class ActorWebfingerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
fields = ['url']
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {}
|
||||
data['subject'] = 'acct:{}'.format(instance.webfinger_subject)
|
||||
data['links'] = [
|
||||
{
|
||||
'rel': 'self',
|
||||
'href': instance.url,
|
||||
'type': 'application/activity+json'
|
||||
}
|
||||
]
|
||||
data['aliases'] = [
|
||||
instance.url
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
class ActivitySerializer(serializers.Serializer):
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField()
|
||||
type = serializers.ChoiceField(
|
||||
choices=[(c, c) for c in activity.ACTIVITY_TYPES])
|
||||
object = serializers.JSONField()
|
||||
|
||||
def validate_object(self, value):
|
||||
try:
|
||||
type = value['type']
|
||||
except KeyError:
|
||||
raise serializers.ValidationError('Missing object type')
|
||||
|
||||
try:
|
||||
object_serializer = OBJECT_SERIALIZERS[type]
|
||||
except KeyError:
|
||||
raise serializers.ValidationError(
|
||||
'Unsupported type {}'.format(type))
|
||||
|
||||
serializer = object_serializer(data=value)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return serializer.data
|
||||
|
||||
def validate_actor(self, value):
|
||||
request_actor = self.context.get('actor')
|
||||
if request_actor and request_actor.url != value:
|
||||
raise serializers.ValidationError(
|
||||
'The actor making the request do not match'
|
||||
' the activity actor'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class ObjectSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
url = serializers.URLField(required=False, allow_null=True)
|
||||
type = serializers.ChoiceField(
|
||||
choices=[(c, c) for c in activity.OBJECT_TYPES])
|
||||
content = serializers.CharField(
|
||||
required=False, allow_null=True)
|
||||
summary = serializers.CharField(
|
||||
required=False, allow_null=True)
|
||||
name = serializers.CharField(
|
||||
required=False, allow_null=True)
|
||||
published = serializers.DateTimeField(
|
||||
required=False, allow_null=True)
|
||||
updated = serializers.DateTimeField(
|
||||
required=False, allow_null=True)
|
||||
to = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
required=False, allow_null=True)
|
||||
cc = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
required=False, allow_null=True)
|
||||
bto = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
required=False, allow_null=True)
|
||||
bcc = serializers.ListField(
|
||||
child=serializers.URLField(),
|
||||
required=False, allow_null=True)
|
||||
|
||||
OBJECT_SERIALIZERS = {
|
||||
t: ObjectSerializer
|
||||
for t in activity.OBJECT_TYPES
|
||||
}
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import logging
|
||||
import requests
|
||||
import requests_http_signature
|
||||
|
||||
from . import exceptions
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def verify(request, public_key):
|
||||
return requests_http_signature.HTTPSignatureAuth.verify(
|
||||
request,
|
||||
key_resolver=lambda **kwargs: public_key
|
||||
key_resolver=lambda **kwargs: public_key,
|
||||
use_auth_header=False,
|
||||
)
|
||||
|
||||
|
||||
|
@ -14,21 +21,35 @@ def verify_django(django_request, public_key):
|
|||
Given a django WSGI request, create an underlying requests.PreparedRequest
|
||||
instance we can verify
|
||||
"""
|
||||
headers = django_request.META.get('headers', {}).copy()
|
||||
headers = utils.clean_wsgi_headers(django_request.META)
|
||||
for h, v in list(headers.items()):
|
||||
# we include lower-cased version of the headers for compatibility
|
||||
# with requests_http_signature
|
||||
headers[h.lower()] = v
|
||||
try:
|
||||
signature = headers['authorization']
|
||||
signature = headers['Signature']
|
||||
except KeyError:
|
||||
raise exceptions.MissingSignature
|
||||
|
||||
url = 'http://noop{}'.format(django_request.path)
|
||||
query = django_request.META['QUERY_STRING']
|
||||
if query:
|
||||
url += '?{}'.format(query)
|
||||
signature_headers = signature.split('headers="')[1].split('",')[0]
|
||||
expected = signature_headers.split(' ')
|
||||
logger.debug('Signature expected headers: %s', expected)
|
||||
for header in expected:
|
||||
try:
|
||||
headers[header]
|
||||
except KeyError:
|
||||
logger.debug('Missing header: %s', header)
|
||||
request = requests.Request(
|
||||
method=django_request.method,
|
||||
url='http://noop',
|
||||
url=url,
|
||||
data=django_request.body,
|
||||
headers=headers)
|
||||
|
||||
for h in request.headers.keys():
|
||||
v = request.headers[h]
|
||||
if v:
|
||||
request.headers[h] = str(v)
|
||||
prepared_request = request.prepare()
|
||||
return verify(request, public_key)
|
||||
|
|
|
@ -4,9 +4,9 @@ from . import views
|
|||
|
||||
router = routers.SimpleRouter(trailing_slash=False)
|
||||
router.register(
|
||||
r'federation/instance',
|
||||
views.InstanceViewSet,
|
||||
'instance')
|
||||
r'federation/instance/actors',
|
||||
views.InstanceActorViewSet,
|
||||
'instance-actors')
|
||||
router.register(
|
||||
r'.well-known',
|
||||
views.WellKnownViewSet,
|
||||
|
|
|
@ -12,3 +12,24 @@ def full_url(path):
|
|||
return root + '/' + path
|
||||
else:
|
||||
return root + path
|
||||
|
||||
|
||||
def clean_wsgi_headers(raw_headers):
|
||||
"""
|
||||
Convert WSGI headers from CONTENT_TYPE to Content-Type notation
|
||||
"""
|
||||
cleaned = {}
|
||||
non_prefixed = [
|
||||
'content_type',
|
||||
'content_length',
|
||||
]
|
||||
for raw_header, value in raw_headers.items():
|
||||
h = raw_header.lower()
|
||||
if not h.startswith('http_') and h not in non_prefixed:
|
||||
continue
|
||||
|
||||
words = h.replace('http_', '', 1).split('_')
|
||||
cleaned_header = '-'.join([w.capitalize() for w in words])
|
||||
cleaned[cleaned_header] = value
|
||||
|
||||
return cleaned
|
||||
|
|
|
@ -5,8 +5,10 @@ from django.http import HttpResponse
|
|||
from rest_framework import viewsets
|
||||
from rest_framework import views
|
||||
from rest_framework import response
|
||||
from rest_framework.decorators import list_route
|
||||
from rest_framework.decorators import list_route, detail_route
|
||||
|
||||
from . import actors
|
||||
from . import authentication
|
||||
from . import renderers
|
||||
from . import serializers
|
||||
from . import webfinger
|
||||
|
@ -19,22 +21,50 @@ class FederationMixin(object):
|
|||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class InstanceViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
authentication_classes = []
|
||||
class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
lookup_field = 'actor'
|
||||
lookup_value_regex = '[a-z]*'
|
||||
authentication_classes = [
|
||||
authentication.SignatureAuthentication]
|
||||
permission_classes = []
|
||||
renderer_classes = [renderers.ActivityPubRenderer]
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def actor(self, request, *args, **kwargs):
|
||||
return response.Response(serializers.repr_instance_actor())
|
||||
def get_object(self):
|
||||
try:
|
||||
return actors.SYSTEM_ACTORS[self.kwargs['actor']]
|
||||
except KeyError:
|
||||
raise Http404
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
system_actor = self.get_object()
|
||||
actor = system_actor.get_actor_instance()
|
||||
serializer = serializers.ActorSerializer(actor)
|
||||
return response.Response(serializer.data, status=200)
|
||||
|
||||
@detail_route(methods=['get', 'post'])
|
||||
def inbox(self, request, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
system_actor = self.get_object()
|
||||
handler = getattr(system_actor, '{}_inbox'.format(
|
||||
request.method.lower()
|
||||
))
|
||||
|
||||
@list_route(methods=['get'])
|
||||
try:
|
||||
data = handler(request.data, actor=request.actor)
|
||||
except NotImplementedError:
|
||||
return response.Response(status=405)
|
||||
return response.Response(data, status=200)
|
||||
|
||||
@detail_route(methods=['get', 'post'])
|
||||
def outbox(self, request, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
system_actor = self.get_object()
|
||||
handler = getattr(system_actor, '{}_outbox'.format(
|
||||
request.method.lower()
|
||||
))
|
||||
try:
|
||||
data = handler(request.data, actor=request.actor)
|
||||
except NotImplementedError:
|
||||
return response.Response(status=405)
|
||||
return response.Response(data, status=200)
|
||||
|
||||
|
||||
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
|
@ -69,6 +99,5 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
|
||||
def handler_acct(self, clean_result):
|
||||
username, hostname = clean_result
|
||||
if username == 'service':
|
||||
return webfinger.serialize_system_acct()
|
||||
return {}
|
||||
actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
|
||||
return serializers.ActorWebfingerSerializer(actor).data
|
||||
|
|
|
@ -2,7 +2,9 @@ from django import forms
|
|||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from . import actors
|
||||
from . import utils
|
||||
|
||||
VALID_RESOURCE_TYPES = ['acct']
|
||||
|
||||
|
||||
|
@ -27,26 +29,11 @@ def clean_acct(acct_string):
|
|||
except ValueError:
|
||||
raise forms.ValidationError('Invalid format')
|
||||
|
||||
if hostname != settings.FEDERATION_HOSTNAME:
|
||||
raise forms.ValidationError('Invalid hostname')
|
||||
if hostname.lower() != settings.FEDERATION_HOSTNAME:
|
||||
raise forms.ValidationError(
|
||||
'Invalid hostname {}'.format(hostname))
|
||||
|
||||
if username != 'service':
|
||||
if username not in actors.SYSTEM_ACTORS:
|
||||
raise forms.ValidationError('Invalid username')
|
||||
|
||||
return username, hostname
|
||||
|
||||
|
||||
def serialize_system_acct():
|
||||
return {
|
||||
'subject': 'acct:service@{}'.format(settings.FEDERATION_HOSTNAME),
|
||||
'aliases': [
|
||||
utils.full_url(reverse('federation:instance-actor'))
|
||||
],
|
||||
'links': [
|
||||
{
|
||||
'rel': 'self',
|
||||
'type': 'application/activity+json',
|
||||
'href': utils.full_url(reverse('federation:instance-actor')),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -61,4 +61,6 @@ django-cacheops>=4,<4.1
|
|||
|
||||
daphne==2.0.4
|
||||
cryptography>=2,<3
|
||||
requests-http-signature==0.0.3
|
||||
# requests-http-signature==0.0.3
|
||||
# clone until the branch is merged and released upstream
|
||||
git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support
|
||||
|
|
|
@ -10,4 +10,5 @@ pytest-mock
|
|||
pytest-sugar
|
||||
pytest-xdist
|
||||
pytest-cov
|
||||
pytest-env
|
||||
requests-mock
|
||||
|
|
|
@ -7,6 +7,12 @@ max-line-length = 120
|
|||
exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
|
||||
|
||||
[tool:pytest]
|
||||
DJANGO_SETTINGS_MODULE=config.settings.test
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
testpaths = tests
|
||||
env =
|
||||
SECRET_KEY=test
|
||||
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
CELERY_BROKER_URL=memory://
|
||||
CELERY_TASK_ALWAYS_EAGER=True
|
||||
CACHEOPS_ENABLED=False
|
||||
FEDERATION_HOSTNAME=test.federation
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import factory
|
||||
import tempfile
|
||||
import shutil
|
||||
import pytest
|
||||
import requests_mock
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache as django_cache
|
||||
from django.test import client
|
||||
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from rest_framework.test import APIClient
|
||||
|
@ -118,6 +120,11 @@ def api_request():
|
|||
return APIRequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_request():
|
||||
return client.RequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def activity_registry():
|
||||
r = record.registry
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_actor(nodb_factories, mocker):
|
||||
actor = nodb_factories['federation.Actor']()
|
||||
mocker.patch(
|
||||
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
|
||||
return_value=actor)
|
||||
yield actor
|
|
@ -0,0 +1,32 @@
|
|||
from funkwhale_api.federation import activity
|
||||
|
||||
def test_deliver(nodb_factories, r_mock, mocker):
|
||||
to = nodb_factories['federation.Actor']()
|
||||
mocker.patch(
|
||||
'funkwhale_api.federation.actors.get_actor',
|
||||
return_value=to)
|
||||
sender = nodb_factories['federation.Actor']()
|
||||
ac = {
|
||||
'id': 'http://test.federation/activity',
|
||||
'type': 'Create',
|
||||
'actor': sender.url,
|
||||
'object': {
|
||||
'id': 'http://test.federation/note',
|
||||
'type': 'Note',
|
||||
'content': 'Hello',
|
||||
}
|
||||
}
|
||||
|
||||
r_mock.post(to.inbox_url)
|
||||
|
||||
activity.deliver(
|
||||
ac,
|
||||
to=[to.url],
|
||||
on_behalf_of=sender,
|
||||
)
|
||||
request = r_mock.request_history[0]
|
||||
|
||||
assert r_mock.called is True
|
||||
assert r_mock.call_count == 1
|
||||
assert request.url == to.inbox_url
|
||||
assert request.headers['content-type'] == 'application/activity+json'
|
|
@ -0,0 +1,190 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import serializers
|
||||
from funkwhale_api.federation import utils
|
||||
|
||||
|
||||
def test_actor_fetching(r_mock):
|
||||
payload = {
|
||||
'id': 'https://actor.mock/users/actor#main-key',
|
||||
'owner': 'test',
|
||||
'publicKeyPem': 'test_pem',
|
||||
}
|
||||
actor_url = 'https://actor.mock/'
|
||||
r_mock.get(actor_url, json=payload)
|
||||
r = actors.get_actor_data(actor_url)
|
||||
|
||||
assert r == payload
|
||||
|
||||
|
||||
def test_get_library(settings, preferences):
|
||||
preferences['federation__public_key'] = 'public_key'
|
||||
expected = {
|
||||
'preferred_username': 'library',
|
||||
'domain': settings.FEDERATION_HOSTNAME,
|
||||
'type': 'Person',
|
||||
'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
|
||||
'manually_approves_followers': True,
|
||||
'url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-detail',
|
||||
kwargs={'actor': 'library'})),
|
||||
'shared_inbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-inbox',
|
||||
kwargs={'actor': 'library'})),
|
||||
'inbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-inbox',
|
||||
kwargs={'actor': 'library'})),
|
||||
'outbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-outbox',
|
||||
kwargs={'actor': 'library'})),
|
||||
'public_key': 'public_key',
|
||||
'summary': 'Bot account to federate with {}\'s library'.format(
|
||||
settings.FEDERATION_HOSTNAME),
|
||||
}
|
||||
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
for key, value in expected.items():
|
||||
assert getattr(actor, key) == value
|
||||
|
||||
|
||||
def test_get_test(settings, preferences):
|
||||
preferences['federation__public_key'] = 'public_key'
|
||||
expected = {
|
||||
'preferred_username': 'test',
|
||||
'domain': settings.FEDERATION_HOSTNAME,
|
||||
'type': 'Person',
|
||||
'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
|
||||
'manually_approves_followers': False,
|
||||
'url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-detail',
|
||||
kwargs={'actor': 'test'})),
|
||||
'shared_inbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-inbox',
|
||||
kwargs={'actor': 'test'})),
|
||||
'inbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-inbox',
|
||||
kwargs={'actor': 'test'})),
|
||||
'outbox_url': utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-outbox',
|
||||
kwargs={'actor': 'test'})),
|
||||
'public_key': 'public_key',
|
||||
'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
|
||||
settings.FEDERATION_HOSTNAME),
|
||||
}
|
||||
actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
|
||||
for key, value in expected.items():
|
||||
assert getattr(actor, key) == value
|
||||
|
||||
|
||||
def test_test_get_outbox():
|
||||
expected = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{}
|
||||
],
|
||||
"id": utils.full_url(
|
||||
reverse(
|
||||
'federation:instance-actors-outbox',
|
||||
kwargs={'actor': 'test'})),
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": 0,
|
||||
"orderedItems": []
|
||||
}
|
||||
|
||||
data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
|
||||
|
||||
assert data == expected
|
||||
|
||||
|
||||
def test_test_post_inbox_requires_authenticated_actor():
|
||||
with pytest.raises(exceptions.PermissionDenied):
|
||||
actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None)
|
||||
|
||||
|
||||
def test_test_post_outbox_validates_actor(nodb_factories):
|
||||
actor = nodb_factories['federation.Actor']()
|
||||
data = {
|
||||
'actor': 'noop'
|
||||
}
|
||||
with pytest.raises(exceptions.ValidationError) as exc_info:
|
||||
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
|
||||
msg = 'The actor making the request do not match'
|
||||
assert msg in exc_info.value
|
||||
|
||||
|
||||
def test_test_post_outbox_handles_create_note(
|
||||
settings, mocker, factories):
|
||||
deliver = mocker.patch(
|
||||
'funkwhale_api.federation.activity.deliver')
|
||||
actor = factories['federation.Actor']()
|
||||
now = timezone.now()
|
||||
mocker.patch('django.utils.timezone.now', return_value=now)
|
||||
data = {
|
||||
'actor': actor.url,
|
||||
'type': 'Create',
|
||||
'id': 'http://test.federation/activity',
|
||||
'object': {
|
||||
'type': 'Note',
|
||||
'id': 'http://test.federation/object',
|
||||
'content': '<p><a>@mention</a> /ping</p>'
|
||||
}
|
||||
}
|
||||
test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
|
||||
expected_note = factories['federation.Note'](
|
||||
id='https://test.federation/activities/note/{}'.format(
|
||||
now.timestamp()
|
||||
),
|
||||
content='Pong!',
|
||||
published=now.isoformat(),
|
||||
inReplyTo=data['object']['id'],
|
||||
cc=[],
|
||||
summary=None,
|
||||
sensitive=False,
|
||||
attributedTo=test_actor.url,
|
||||
attachment=[],
|
||||
to=[actor.url],
|
||||
url='https://{}/activities/note/{}'.format(
|
||||
settings.FEDERATION_HOSTNAME, now.timestamp()
|
||||
),
|
||||
tag=[{
|
||||
'href': actor.url,
|
||||
'name': actor.mention_username,
|
||||
'type': 'Mention',
|
||||
}]
|
||||
)
|
||||
expected_activity = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{}
|
||||
],
|
||||
'actor': test_actor.url,
|
||||
'id': 'https://{}/activities/note/{}/activity'.format(
|
||||
settings.FEDERATION_HOSTNAME, now.timestamp()
|
||||
),
|
||||
'to': actor.url,
|
||||
'type': 'Create',
|
||||
'published': now.isoformat(),
|
||||
'object': expected_note,
|
||||
'cc': [],
|
||||
}
|
||||
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
|
||||
deliver.assert_called_once_with(
|
||||
expected_activity,
|
||||
to=[actor.url],
|
||||
on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
|
||||
)
|
|
@ -0,0 +1,42 @@
|
|||
from funkwhale_api.federation import authentication
|
||||
from funkwhale_api.federation import keys
|
||||
from funkwhale_api.federation import signing
|
||||
|
||||
|
||||
def test_authenticate(nodb_factories, mocker, api_request):
|
||||
private, public = keys.get_key_pair()
|
||||
actor_url = 'https://test.federation/actor'
|
||||
mocker.patch(
|
||||
'funkwhale_api.federation.actors.get_actor_data',
|
||||
return_value={
|
||||
'id': actor_url,
|
||||
'outbox': 'https://test.com',
|
||||
'inbox': 'https://test.com',
|
||||
'publicKey': {
|
||||
'publicKeyPem': public.decode('utf-8'),
|
||||
'owner': actor_url,
|
||||
'id': actor_url + '#main-key',
|
||||
}
|
||||
})
|
||||
signed_request = nodb_factories['federation.SignedRequest'](
|
||||
auth__key=private,
|
||||
auth__key_id=actor_url + '#main-key',
|
||||
auth__headers=[
|
||||
'date',
|
||||
]
|
||||
)
|
||||
prepared = signed_request.prepare()
|
||||
django_request = api_request.get(
|
||||
'/',
|
||||
**{
|
||||
'HTTP_DATE': prepared.headers['date'],
|
||||
'HTTP_SIGNATURE': prepared.headers['signature'],
|
||||
}
|
||||
)
|
||||
authenticator = authentication.SignatureAuthentication()
|
||||
user, _ = authenticator.authenticate(django_request)
|
||||
actor = django_request.actor
|
||||
|
||||
assert user.is_anonymous is True
|
||||
assert actor.public_key == public.decode('utf-8')
|
||||
assert actor.url == actor_url
|
|
@ -1,16 +1,25 @@
|
|||
import pytest
|
||||
|
||||
from funkwhale_api.federation import keys
|
||||
|
||||
|
||||
def test_public_key_fetching(r_mock):
|
||||
payload = {
|
||||
'id': 'https://actor.mock/users/actor#main-key',
|
||||
'owner': 'test',
|
||||
'publicKeyPem': 'test_pem',
|
||||
}
|
||||
actor = 'https://actor.mock/'
|
||||
r_mock.get(actor, json={'publicKey': payload})
|
||||
r = keys.get_public_key(actor)
|
||||
@pytest.mark.parametrize('raw, expected', [
|
||||
('algorithm="test",keyId="https://test.com"', 'https://test.com'),
|
||||
('keyId="https://test.com",algorithm="test"', 'https://test.com'),
|
||||
])
|
||||
def test_get_key_from_header(raw, expected):
|
||||
r = keys.get_key_id_from_signature_header(raw)
|
||||
assert r == expected
|
||||
|
||||
assert r['id'] == payload['id']
|
||||
assert r['owner'] == payload['owner']
|
||||
assert r['public_key_pem'] == payload['publicKeyPem']
|
||||
|
||||
@pytest.mark.parametrize('raw', [
|
||||
'algorithm="test",keyid="badCase"',
|
||||
'algorithm="test",wrong="wrong"',
|
||||
'keyId = "wrong"',
|
||||
'keyId=\'wrong\'',
|
||||
'keyId="notanurl"',
|
||||
'keyId="wrong://test.com"',
|
||||
])
|
||||
def test_get_key_from_header_invalid(raw):
|
||||
with pytest.raises(ValueError):
|
||||
keys.get_key_id_from_signature_header(raw)
|
||||
|
|
|
@ -1,36 +1,146 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.federation import keys
|
||||
from funkwhale_api.federation import models
|
||||
from funkwhale_api.federation import serializers
|
||||
|
||||
|
||||
def test_repr_instance_actor(db, preferences, settings):
|
||||
_, public_key = keys.get_key_pair()
|
||||
preferences['federation__public_key'] = public_key.decode('utf-8')
|
||||
settings.FEDERATION_HOSTNAME = 'test.federation'
|
||||
settings.FUNKWHALE_URL = 'https://test.federation'
|
||||
actor_url = settings.FUNKWHALE_URL + reverse('federation:instance-actor')
|
||||
inbox_url = settings.FUNKWHALE_URL + reverse('federation:instance-inbox')
|
||||
outbox_url = settings.FUNKWHALE_URL + reverse('federation:instance-outbox')
|
||||
def test_actor_serializer_from_ap(db):
|
||||
payload = {
|
||||
'id': 'https://test.federation/user',
|
||||
'type': 'Person',
|
||||
'following': 'https://test.federation/user/following',
|
||||
'followers': 'https://test.federation/user/followers',
|
||||
'inbox': 'https://test.federation/user/inbox',
|
||||
'outbox': 'https://test.federation/user/outbox',
|
||||
'preferredUsername': 'user',
|
||||
'name': 'Real User',
|
||||
'summary': 'Hello world',
|
||||
'url': 'https://test.federation/@user',
|
||||
'manuallyApprovesFollowers': False,
|
||||
'publicKey': {
|
||||
'id': 'https://test.federation/user#main-key',
|
||||
'owner': 'https://test.federation/user',
|
||||
'publicKeyPem': 'yolo'
|
||||
},
|
||||
'endpoints': {
|
||||
'sharedInbox': 'https://test.federation/inbox'
|
||||
},
|
||||
}
|
||||
|
||||
serializer = serializers.ActorSerializer(data=payload)
|
||||
assert serializer.is_valid()
|
||||
|
||||
actor = serializer.build()
|
||||
|
||||
assert actor.url == payload['id']
|
||||
assert actor.inbox_url == payload['inbox']
|
||||
assert actor.outbox_url == payload['outbox']
|
||||
assert actor.shared_inbox_url == payload['endpoints']['sharedInbox']
|
||||
assert actor.followers_url == payload['followers']
|
||||
assert actor.following_url == payload['following']
|
||||
assert actor.public_key == payload['publicKey']['publicKeyPem']
|
||||
assert actor.preferred_username == payload['preferredUsername']
|
||||
assert actor.name == payload['name']
|
||||
assert actor.domain == 'test.federation'
|
||||
assert actor.summary == payload['summary']
|
||||
assert actor.type == 'Person'
|
||||
assert actor.manually_approves_followers == payload['manuallyApprovesFollowers']
|
||||
|
||||
|
||||
def test_actor_serializer_only_mandatory_field_from_ap(db):
|
||||
payload = {
|
||||
'id': 'https://test.federation/user',
|
||||
'type': 'Person',
|
||||
'following': 'https://test.federation/user/following',
|
||||
'followers': 'https://test.federation/user/followers',
|
||||
'inbox': 'https://test.federation/user/inbox',
|
||||
'outbox': 'https://test.federation/user/outbox',
|
||||
'preferredUsername': 'user',
|
||||
}
|
||||
|
||||
serializer = serializers.ActorSerializer(data=payload)
|
||||
assert serializer.is_valid()
|
||||
|
||||
actor = serializer.build()
|
||||
|
||||
assert actor.url == payload['id']
|
||||
assert actor.inbox_url == payload['inbox']
|
||||
assert actor.outbox_url == payload['outbox']
|
||||
assert actor.followers_url == payload['followers']
|
||||
assert actor.following_url == payload['following']
|
||||
assert actor.preferred_username == payload['preferredUsername']
|
||||
assert actor.domain == 'test.federation'
|
||||
assert actor.type == 'Person'
|
||||
assert actor.manually_approves_followers is None
|
||||
|
||||
|
||||
def test_actor_serializer_to_ap():
|
||||
expected = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': actor_url,
|
||||
'type': 'Person',
|
||||
'preferredUsername': 'service',
|
||||
'name': 'Service Bot - test.federation',
|
||||
'summary': 'Bot account for federating with test.federation',
|
||||
'inbox': inbox_url,
|
||||
'outbox': outbox_url,
|
||||
'publicKey': {
|
||||
'id': '{}#main-key'.format(actor_url),
|
||||
'owner': actor_url,
|
||||
'publicKeyPem': public_key.decode('utf-8')
|
||||
},
|
||||
'id': 'https://test.federation/user',
|
||||
'type': 'Person',
|
||||
'following': 'https://test.federation/user/following',
|
||||
'followers': 'https://test.federation/user/followers',
|
||||
'inbox': 'https://test.federation/user/inbox',
|
||||
'outbox': 'https://test.federation/user/outbox',
|
||||
'preferredUsername': 'user',
|
||||
'name': 'Real User',
|
||||
'summary': 'Hello world',
|
||||
'manuallyApprovesFollowers': False,
|
||||
'publicKey': {
|
||||
'id': 'https://test.federation/user#main-key',
|
||||
'owner': 'https://test.federation/user',
|
||||
'publicKeyPem': 'yolo'
|
||||
},
|
||||
'endpoints': {
|
||||
'sharedInbox': 'https://test.federation/inbox'
|
||||
},
|
||||
}
|
||||
ac = models.Actor(
|
||||
url=expected['id'],
|
||||
inbox_url=expected['inbox'],
|
||||
outbox_url=expected['outbox'],
|
||||
shared_inbox_url=expected['endpoints']['sharedInbox'],
|
||||
followers_url=expected['followers'],
|
||||
following_url=expected['following'],
|
||||
public_key=expected['publicKey']['publicKeyPem'],
|
||||
preferred_username=expected['preferredUsername'],
|
||||
name=expected['name'],
|
||||
domain='test.federation',
|
||||
summary=expected['summary'],
|
||||
type='Person',
|
||||
manually_approves_followers=False,
|
||||
|
||||
assert expected == serializers.repr_instance_actor()
|
||||
)
|
||||
serializer = serializers.ActorSerializer(ac)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_webfinger_serializer():
|
||||
expected = {
|
||||
'subject': 'acct:service@test.federation',
|
||||
'links': [
|
||||
{
|
||||
'rel': 'self',
|
||||
'href': 'https://test.federation/federation/instance/actor',
|
||||
'type': 'application/activity+json',
|
||||
}
|
||||
],
|
||||
'aliases': [
|
||||
'https://test.federation/federation/instance/actor',
|
||||
]
|
||||
}
|
||||
actor = models.Actor(
|
||||
url=expected['links'][0]['href'],
|
||||
preferred_username='service',
|
||||
domain='test.federation',
|
||||
)
|
||||
serializer = serializers.ActorWebfingerSerializer(actor)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
|
|
@ -7,23 +7,23 @@ from funkwhale_api.federation import signing
|
|||
from funkwhale_api.federation import keys
|
||||
|
||||
|
||||
def test_can_sign_and_verify_request(factories):
|
||||
private, public = factories['federation.KeyPair']()
|
||||
auth = factories['federation.SignatureAuth'](key=private)
|
||||
request = factories['federation.SignedRequest'](
|
||||
def test_can_sign_and_verify_request(nodb_factories):
|
||||
private, public = nodb_factories['federation.KeyPair']()
|
||||
auth = nodb_factories['federation.SignatureAuth'](key=private)
|
||||
request = nodb_factories['federation.SignedRequest'](
|
||||
auth=auth
|
||||
)
|
||||
prepared_request = request.prepare()
|
||||
assert 'date' in prepared_request.headers
|
||||
assert 'authorization' in prepared_request.headers
|
||||
assert prepared_request.headers['authorization'].startswith('Signature')
|
||||
assert signing.verify(prepared_request, public) is None
|
||||
assert 'signature' in prepared_request.headers
|
||||
assert signing.verify(
|
||||
prepared_request, public) is None
|
||||
|
||||
|
||||
def test_can_sign_and_verify_request_digest(factories):
|
||||
private, public = factories['federation.KeyPair']()
|
||||
auth = factories['federation.SignatureAuth'](key=private)
|
||||
request = factories['federation.SignedRequest'](
|
||||
def test_can_sign_and_verify_request_digest(nodb_factories):
|
||||
private, public = nodb_factories['federation.KeyPair']()
|
||||
auth = nodb_factories['federation.SignatureAuth'](key=private)
|
||||
request = nodb_factories['federation.SignedRequest'](
|
||||
auth=auth,
|
||||
method='post',
|
||||
data=b'hello=world'
|
||||
|
@ -31,70 +31,80 @@ def test_can_sign_and_verify_request_digest(factories):
|
|||
prepared_request = request.prepare()
|
||||
assert 'date' in prepared_request.headers
|
||||
assert 'digest' in prepared_request.headers
|
||||
assert 'authorization' in prepared_request.headers
|
||||
assert prepared_request.headers['authorization'].startswith('Signature')
|
||||
assert 'signature' in prepared_request.headers
|
||||
assert signing.verify(prepared_request, public) is None
|
||||
|
||||
|
||||
def test_verify_fails_with_wrong_key(factories):
|
||||
wrong_private, wrong_public = factories['federation.KeyPair']()
|
||||
request = factories['federation.SignedRequest']()
|
||||
def test_verify_fails_with_wrong_key(nodb_factories):
|
||||
wrong_private, wrong_public = nodb_factories['federation.KeyPair']()
|
||||
request = nodb_factories['federation.SignedRequest']()
|
||||
prepared_request = request.prepare()
|
||||
|
||||
with pytest.raises(cryptography.exceptions.InvalidSignature):
|
||||
signing.verify(prepared_request, wrong_public)
|
||||
|
||||
|
||||
def test_can_verify_django_request(factories, api_request):
|
||||
def test_can_verify_django_request(factories, fake_request):
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key
|
||||
auth__key=private_key,
|
||||
auth__headers=[
|
||||
'date',
|
||||
]
|
||||
)
|
||||
prepared = signed_request.prepare()
|
||||
django_request = api_request.get(
|
||||
django_request = fake_request.get(
|
||||
'/',
|
||||
headers={
|
||||
'Date': prepared.headers['date'],
|
||||
'Authorization': prepared.headers['authorization'],
|
||||
**{
|
||||
'HTTP_DATE': prepared.headers['date'],
|
||||
'HTTP_SIGNATURE': prepared.headers['signature'],
|
||||
}
|
||||
)
|
||||
assert signing.verify_django(django_request, public_key) is None
|
||||
|
||||
|
||||
def test_can_verify_django_request_digest(factories, api_request):
|
||||
def test_can_verify_django_request_digest(factories, fake_request):
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key,
|
||||
method='post',
|
||||
data=b'hello=world'
|
||||
data=b'hello=world',
|
||||
auth__headers=[
|
||||
'date',
|
||||
'digest',
|
||||
]
|
||||
)
|
||||
prepared = signed_request.prepare()
|
||||
django_request = api_request.post(
|
||||
django_request = fake_request.post(
|
||||
'/',
|
||||
headers={
|
||||
'Date': prepared.headers['date'],
|
||||
'Digest': prepared.headers['digest'],
|
||||
'Authorization': prepared.headers['authorization'],
|
||||
**{
|
||||
'HTTP_DATE': prepared.headers['date'],
|
||||
'HTTP_DIGEST': prepared.headers['digest'],
|
||||
'HTTP_SIGNATURE': prepared.headers['signature'],
|
||||
}
|
||||
)
|
||||
|
||||
assert signing.verify_django(django_request, public_key) is None
|
||||
|
||||
|
||||
def test_can_verify_django_request_digest_failure(factories, api_request):
|
||||
def test_can_verify_django_request_digest_failure(factories, fake_request):
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key,
|
||||
method='post',
|
||||
data=b'hello=world'
|
||||
data=b'hello=world',
|
||||
auth__headers=[
|
||||
'date',
|
||||
'digest',
|
||||
]
|
||||
)
|
||||
prepared = signed_request.prepare()
|
||||
django_request = api_request.post(
|
||||
django_request = fake_request.post(
|
||||
'/',
|
||||
headers={
|
||||
'Date': prepared.headers['date'],
|
||||
'Digest': prepared.headers['digest'] + 'noop',
|
||||
'Authorization': prepared.headers['authorization'],
|
||||
**{
|
||||
'HTTP_DATE': prepared.headers['date'],
|
||||
'HTTP_DIGEST': prepared.headers['digest'] + 'noop',
|
||||
'HTTP_SIGNATURE': prepared.headers['signature'],
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -102,17 +112,20 @@ def test_can_verify_django_request_digest_failure(factories, api_request):
|
|||
signing.verify_django(django_request, public_key)
|
||||
|
||||
|
||||
def test_can_verify_django_request_failure(factories, api_request):
|
||||
def test_can_verify_django_request_failure(factories, fake_request):
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key
|
||||
auth__key=private_key,
|
||||
auth__headers=[
|
||||
'date',
|
||||
]
|
||||
)
|
||||
prepared = signed_request.prepare()
|
||||
django_request = api_request.get(
|
||||
django_request = fake_request.get(
|
||||
'/',
|
||||
headers={
|
||||
'Date': 'Wrong',
|
||||
'Authorization': prepared.headers['authorization'],
|
||||
**{
|
||||
'HTTP_DATE': 'Wrong',
|
||||
'HTTP_SIGNATURE': prepared.headers['signature'],
|
||||
}
|
||||
)
|
||||
with pytest.raises(cryptography.exceptions.InvalidSignature):
|
||||
|
|
|
@ -12,3 +12,37 @@ from funkwhale_api.federation import utils
|
|||
def test_full_url(settings, url, path, expected):
|
||||
settings.FUNKWHALE_URL = url
|
||||
assert utils.full_url(path) == expected
|
||||
|
||||
|
||||
def test_extract_headers_from_meta():
|
||||
wsgi_headers = {
|
||||
'HTTP_HOST': 'nginx',
|
||||
'HTTP_X_REAL_IP': '172.20.0.4',
|
||||
'HTTP_X_FORWARDED_FOR': '188.165.228.227, 172.20.0.4',
|
||||
'HTTP_X_FORWARDED_PROTO': 'http',
|
||||
'HTTP_X_FORWARDED_HOST': 'localhost:80',
|
||||
'HTTP_X_FORWARDED_PORT': '80',
|
||||
'HTTP_CONNECTION': 'close',
|
||||
'CONTENT_LENGTH': '1155',
|
||||
'CONTENT_TYPE': 'txt/application',
|
||||
'HTTP_SIGNATURE': 'Hello',
|
||||
'HTTP_DATE': 'Sat, 31 Mar 2018 13:53:55 GMT',
|
||||
'HTTP_USER_AGENT': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'}
|
||||
|
||||
cleaned_headers = utils.clean_wsgi_headers(wsgi_headers)
|
||||
|
||||
expected = {
|
||||
'Host': 'nginx',
|
||||
'X-Real-Ip': '172.20.0.4',
|
||||
'X-Forwarded-For': '188.165.228.227, 172.20.0.4',
|
||||
'X-Forwarded-Proto': 'http',
|
||||
'X-Forwarded-Host': 'localhost:80',
|
||||
'X-Forwarded-Port': '80',
|
||||
'Connection': 'close',
|
||||
'Content-Length': '1155',
|
||||
'Content-Type': 'txt/application',
|
||||
'Signature': 'Hello',
|
||||
'Date': 'Sat, 31 Mar 2018 13:53:55 GMT',
|
||||
'User-Agent': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'
|
||||
}
|
||||
assert cleaned_headers == expected
|
||||
|
|
|
@ -2,29 +2,35 @@ from django.urls import reverse
|
|||
|
||||
import pytest
|
||||
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import serializers
|
||||
from funkwhale_api.federation import webfinger
|
||||
|
||||
|
||||
def test_instance_actor(db, settings, api_client):
|
||||
settings.FUNKWHALE_URL = 'http://test.com'
|
||||
url = reverse('federation:instance-actor')
|
||||
|
||||
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
|
||||
def test_instance_actors(system_actor, db, settings, api_client):
|
||||
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
|
||||
url = reverse(
|
||||
'federation:instance-actors-detail',
|
||||
kwargs={'actor': system_actor})
|
||||
response = api_client.get(url)
|
||||
serializer = serializers.ActorSerializer(actor)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == serializers.repr_instance_actor()
|
||||
assert response.data == serializer.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route', [
|
||||
'instance-outbox',
|
||||
'instance-inbox',
|
||||
'instance-actor',
|
||||
'well-known-webfinger',
|
||||
@pytest.mark.parametrize('route,kwargs', [
|
||||
('instance-actors-outbox', {'actor': 'library'}),
|
||||
('instance-actors-inbox', {'actor': 'library'}),
|
||||
('instance-actors-detail', {'actor': 'library'}),
|
||||
('well-known-webfinger', {}),
|
||||
])
|
||||
def test_instance_inbox_405_if_federation_disabled(
|
||||
db, settings, api_client, route):
|
||||
def test_instance_endpoints_405_if_federation_disabled(
|
||||
authenticated_actor, db, settings, api_client, route, kwargs):
|
||||
settings.FEDERATION_ENABLED = False
|
||||
url = reverse('federation:{}'.format(route))
|
||||
url = reverse('federation:{}'.format(route), kwargs=kwargs)
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
@ -33,7 +39,6 @@ def test_instance_inbox_405_if_federation_disabled(
|
|||
def test_wellknown_webfinger_validates_resource(
|
||||
db, api_client, settings, mocker):
|
||||
clean = mocker.spy(webfinger, 'clean_resource')
|
||||
settings.FEDERATION_ENABLED = True
|
||||
url = reverse('federation:well-known-webfinger')
|
||||
response = api_client.get(url, data={'resource': 'something'})
|
||||
|
||||
|
@ -45,14 +50,15 @@ def test_wellknown_webfinger_validates_resource(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
|
||||
def test_wellknown_webfinger_system(
|
||||
db, api_client, settings, mocker):
|
||||
settings.FEDERATION_ENABLED = True
|
||||
settings.FEDERATION_HOSTNAME = 'test.federation'
|
||||
system_actor, db, api_client, settings, mocker):
|
||||
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
|
||||
url = reverse('federation:well-known-webfinger')
|
||||
response = api_client.get(
|
||||
url, data={'resource': 'acct:service@test.federation'})
|
||||
url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
|
||||
serializer = serializers.ActorWebfingerSerializer(actor)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == 'application/jrd+json'
|
||||
assert response.data == webfinger.serialize_system_acct()
|
||||
assert response.data == serializer.data
|
||||
|
|
|
@ -25,42 +25,18 @@ def test_webfinger_clean_resource_errors(resource, message):
|
|||
|
||||
|
||||
def test_webfinger_clean_acct(settings):
|
||||
settings.FEDERATION_HOSTNAME = 'test.federation'
|
||||
username, hostname = webfinger.clean_acct('service@test.federation')
|
||||
assert username == 'service'
|
||||
username, hostname = webfinger.clean_acct('library@test.federation')
|
||||
assert username == 'library'
|
||||
assert hostname == 'test.federation'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('resource,message', [
|
||||
('service', 'Invalid format'),
|
||||
('service@test.com', 'Invalid hostname'),
|
||||
('service@test.com', 'Invalid hostname test.com'),
|
||||
('noop@test.federation', 'Invalid account'),
|
||||
])
|
||||
def test_webfinger_clean_acct_errors(resource, message, settings):
|
||||
settings.FEDERATION_HOSTNAME = 'test.federation'
|
||||
|
||||
with pytest.raises(forms.ValidationError) as excinfo:
|
||||
webfinger.clean_resource(resource)
|
||||
|
||||
assert message == str(excinfo)
|
||||
|
||||
|
||||
def test_service_serializer(settings):
|
||||
settings.FEDERATION_HOSTNAME = 'test.federation'
|
||||
settings.FUNKWHALE_URL = 'https://test.federation'
|
||||
|
||||
expected = {
|
||||
'subject': 'acct:service@test.federation',
|
||||
'links': [
|
||||
{
|
||||
'rel': 'self',
|
||||
'href': 'https://test.federation/federation/instance/actor',
|
||||
'type': 'application/activity+json',
|
||||
}
|
||||
],
|
||||
'aliases': [
|
||||
'https://test.federation/federation/instance/actor',
|
||||
]
|
||||
}
|
||||
|
||||
assert expected == webfinger.serialize_system_acct()
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Implemented a @test@yourfunkwhaledomain bot to ensure federation works properly. Send it "/ping" and it will answer back :)
|
10
dev.yml
10
dev.yml
|
@ -38,13 +38,8 @@ services:
|
|||
- redis
|
||||
command: celery -A funkwhale_api.taskapp worker -l debug
|
||||
environment:
|
||||
- "DJANGO_ALLOWED_HOSTS=localhost"
|
||||
- "DJANGO_SETTINGS_MODULE=config.settings.local"
|
||||
- "DJANGO_SECRET_KEY=dev"
|
||||
- C_FORCE_ROOT=true
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
- "FUNKWHALE_URL=http://localhost"
|
||||
volumes:
|
||||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
|
@ -60,13 +55,8 @@ services:
|
|||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
environment:
|
||||
- "PYTHONDONTWRITEBYTECODE=true"
|
||||
- "DJANGO_ALLOWED_HOSTS=localhost,nginx"
|
||||
- "DJANGO_SETTINGS_MODULE=config.settings.local"
|
||||
- "DJANGO_SECRET_KEY=dev"
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
- "FUNKWHALE_URL=http://localhost"
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
#!/bin/bash -eux
|
||||
|
||||
FIRST_HOST=$(echo ${DJANGO_ALLOWED_HOSTS} | cut -d, -f1)
|
||||
echo "Copying template file..."
|
||||
cp /etc/nginx/funkwhale_proxy.conf{.template,}
|
||||
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host localhost:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FIRST_HOST}:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FIRST_HOST}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
|
||||
cat /etc/nginx/funkwhale_proxy.conf
|
||||
|
|
Loading…
Reference in New Issue