Merge branch 'federation-actor-public-key' into 'develop'
Federation actor public key See merge request funkwhale/funkwhale!115
This commit is contained in:
commit
7cf097626b
|
@ -86,3 +86,4 @@ front/selenium-debug.log
|
|||
docs/_build
|
||||
|
||||
data/
|
||||
.env
|
||||
|
|
47
CHANGELOG
47
CHANGELOG
|
@ -3,6 +3,53 @@ Changelog
|
|||
|
||||
.. towncrier
|
||||
|
||||
Release notes:
|
||||
|
||||
Preparing for federation
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
In order to prepare for federation (see #136 and #137), new API endpoints
|
||||
have been added under /federation and /.well-known/webfinger.
|
||||
|
||||
For these endpoints to work, you will need to update your nginx configuration,
|
||||
and add the following snippets::
|
||||
|
||||
location /federation/ {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://funkwhale-api/federation/;
|
||||
}
|
||||
|
||||
location /.well-known/webfinger {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://funkwhale-api/.well-known/webfinger;
|
||||
}
|
||||
|
||||
This will ensure federation endpoints will be reachable in the future.
|
||||
You can of course skip this part if you know you will not federate your instance.
|
||||
|
||||
A new ``FEDERATION_ENABLED`` env var have also been added to control wether
|
||||
federation is enabled or not on the application side. This settings defaults
|
||||
to True, which should have no consequencies at the moment, since actual
|
||||
federation is not implemented and the only available endpoints are for
|
||||
testing purposes.
|
||||
|
||||
Add ``FEDERATION_ENABLED=false`` to your .env file to disable federation
|
||||
on the application side.
|
||||
|
||||
The last step involves generating RSA private and public keys for signing
|
||||
your instance requests on the federation. This can be done via::
|
||||
|
||||
# on docker setups
|
||||
docker-compose --rm api python manage.py generate_keys --no-input
|
||||
|
||||
# on non-docker setups
|
||||
source /srv/funkwhale/virtualenv/bin/activate
|
||||
source /srv/funkwhale/load_env
|
||||
python manage.py generate_keys --no-input
|
||||
|
||||
That's it :)
|
||||
|
||||
|
||||
0.7 (2018-03-21)
|
||||
----------------
|
||||
|
||||
|
|
13
README.rst
13
README.rst
|
@ -73,6 +73,19 @@ via the following command::
|
|||
docker-compose -f dev.yml build
|
||||
|
||||
|
||||
Creating your env file
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
We provide a working .env.dev configuration file that is suitable for
|
||||
development. However, to enable customization on your machine, you should
|
||||
also create a .env file that will hold your personal environment
|
||||
variables (those will not be commited to the project).
|
||||
|
||||
Create it like this::
|
||||
|
||||
touch .env
|
||||
|
||||
|
||||
Database management
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/
|
|||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
import os
|
||||
import environ
|
||||
from funkwhale_api import __version__
|
||||
|
@ -24,8 +25,13 @@ try:
|
|||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
||||
FUNKWHALE_URL = env('FUNKWHALE_URL')
|
||||
FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc
|
||||
|
||||
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
|
||||
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
|
||||
|
||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
||||
|
||||
# APP CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
|
@ -395,4 +401,5 @@ ACCOUNT_USERNAME_BLACKLIST = [
|
|||
'owner',
|
||||
'superuser',
|
||||
'staff',
|
||||
'service',
|
||||
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
|
||||
|
|
|
@ -13,6 +13,9 @@ urlpatterns = [
|
|||
url(settings.ADMIN_URL, admin.site.urls),
|
||||
|
||||
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
|
||||
url(r'^', include(
|
||||
('funkwhale_api.federation.urls', 'federation'),
|
||||
namespace="federation")),
|
||||
url(r'^api/v1/auth/', include('rest_auth.urls')),
|
||||
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
|
||||
url(r'^accounts/', include('allauth.urls')),
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
from django.forms import widgets
|
||||
|
||||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
federation = types.Section('federation')
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class FederationPrivateKey(types.StringPreference):
|
||||
show_in_api = False
|
||||
section = federation
|
||||
name = 'private_key'
|
||||
default = ''
|
||||
help_text = (
|
||||
'Instance private key, used for signing federation HTTP requests'
|
||||
)
|
||||
verbose_name = (
|
||||
'Instance private key (keep it secret, do not change it)'
|
||||
)
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class FederationPublicKey(types.StringPreference):
|
||||
show_in_api = False
|
||||
section = federation
|
||||
name = 'public_key'
|
||||
default = ''
|
||||
help_text = (
|
||||
'Instance public key, used for signing federation HTTP requests'
|
||||
)
|
||||
verbose_name = (
|
||||
'Instance public key (do not change it)'
|
||||
)
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
|
||||
class MalformedPayload(ValueError):
|
||||
pass
|
|
@ -4,16 +4,16 @@ import requests_http_signature
|
|||
|
||||
from funkwhale_api.factories import registry
|
||||
|
||||
from . import signing
|
||||
from . import keys
|
||||
|
||||
|
||||
registry.register(signing.get_key_pair, name='federation.KeyPair')
|
||||
registry.register(keys.get_key_pair, name='federation.KeyPair')
|
||||
|
||||
|
||||
@registry.register(name='federation.SignatureAuth')
|
||||
class SignatureAuthFactory(factory.Factory):
|
||||
algorithm = 'rsa-sha256'
|
||||
key = factory.LazyFunction(lambda: signing.get_key_pair()[0])
|
||||
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
|
||||
key_id = factory.Faker('url')
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
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 requests
|
||||
|
||||
from . import exceptions
|
||||
|
||||
|
||||
def get_key_pair(size=2048):
|
||||
key = rsa.generate_private_key(
|
||||
backend=crypto_default_backend(),
|
||||
public_exponent=65537,
|
||||
key_size=size
|
||||
)
|
||||
private_key = key.private_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PrivateFormat.PKCS8,
|
||||
crypto_serialization.NoEncryption())
|
||||
public_key = key.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.PKCS1
|
||||
)
|
||||
|
||||
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()
|
||||
try:
|
||||
return {
|
||||
'public_key_pem': payload['publicKey']['publicKeyPem'],
|
||||
'id': payload['publicKey']['id'],
|
||||
'owner': payload['publicKey']['owner'],
|
||||
}
|
||||
except KeyError:
|
||||
raise exceptions.MalformedPayload(str(payload))
|
|
@ -0,0 +1,53 @@
|
|||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from funkwhale_api.federation import keys
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
'Generate a public/private key pair for your instance,'
|
||||
' for federation purposes. If a key pair already exists, does nothing.'
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--replace',
|
||||
action='store_true',
|
||||
dest='replace',
|
||||
default=False,
|
||||
help='Replace existing key pair, if any',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--noinput', '--no-input', action='store_false', dest='interactive',
|
||||
help="Do NOT prompt the user for input of any kind.",
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
preferences = global_preferences_registry.manager()
|
||||
existing_public = preferences['federation__public_key']
|
||||
existing_private = preferences['federation__public_key']
|
||||
|
||||
if existing_public or existing_private and not options['replace']:
|
||||
raise CommandError(
|
||||
'Keys are already present! '
|
||||
'Replace them with --replace if you know what you are doing.')
|
||||
|
||||
if options['interactive']:
|
||||
message = (
|
||||
'Are you sure you want to do this?\n\n'
|
||||
"Type 'yes' to continue, or 'no' to cancel: "
|
||||
)
|
||||
if input(''.join(message)) != 'yes':
|
||||
raise CommandError("Operation cancelled.")
|
||||
private, public = keys.get_key_pair()
|
||||
preferences['federation__public_key'] = public.decode('utf-8')
|
||||
preferences['federation__private_key'] = private.decode('utf-8')
|
||||
|
||||
self.stdout.write(
|
||||
'Your new key pair was generated.'
|
||||
'Your public key is now:\n\n{}'.format(public.decode('utf-8'))
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
|
||||
class ActivityPubRenderer(JSONRenderer):
|
||||
media_type = 'application/activity+json'
|
||||
|
||||
|
||||
class WebfingerRenderer(JSONRenderer):
|
||||
media_type = 'application/jrd+json'
|
|
@ -0,0 +1,21 @@
|
|||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from . import utils
|
||||
|
||||
|
||||
def repr_instance_actor():
|
||||
"""
|
||||
We do not use a serializer here, since it's pretty static
|
||||
"""
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': utils.full_url(reverse('federation:instance-actor')),
|
||||
'type': 'Service',
|
||||
'inbox': utils.full_url(reverse('federation:instance-inbox')),
|
||||
'outbox': utils.full_url(reverse('federation:instance-outbox')),
|
||||
}
|
|
@ -1,28 +1,6 @@
|
|||
import requests
|
||||
import requests_http_signature
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_key_pair(size=2048):
|
||||
key = rsa.generate_private_key(
|
||||
backend=crypto_default_backend(),
|
||||
public_exponent=65537,
|
||||
key_size=size
|
||||
)
|
||||
private_key = key.private_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PrivateFormat.PKCS8,
|
||||
crypto_serialization.NoEncryption())
|
||||
public_key = key.public_key().public_bytes(
|
||||
crypto_serialization.Encoding.PEM,
|
||||
crypto_serialization.PublicFormat.PKCS1
|
||||
)
|
||||
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
def verify(request, public_key):
|
||||
return requests_http_signature.HTTPSignatureAuth.verify(
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.SimpleRouter(trailing_slash=False)
|
||||
router.register(
|
||||
r'instance',
|
||||
views.InstanceViewSet,
|
||||
'instance')
|
||||
router.register(
|
||||
r'.well-known',
|
||||
views.WellKnownViewSet,
|
||||
'well-known')
|
||||
|
||||
urlpatterns = router.urls
|
|
@ -0,0 +1,14 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
def full_url(path):
|
||||
"""
|
||||
Given a relative path, return a full url usable for federation purpose
|
||||
"""
|
||||
root = settings.FUNKWHALE_URL
|
||||
if path.startswith('/') and root.endswith('/'):
|
||||
return root + path[1:]
|
||||
elif not path.startswith('/') and not root.endswith('/'):
|
||||
return root + '/' + path
|
||||
else:
|
||||
return root + path
|
|
@ -0,0 +1,74 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
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 . import renderers
|
||||
from . import serializers
|
||||
from . import webfinger
|
||||
|
||||
|
||||
class FederationMixin(object):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not settings.FEDERATION_ENABLED:
|
||||
return HttpResponse(status=405)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class InstanceViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
renderer_classes = [renderers.ActivityPubRenderer]
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def actor(self, request, *args, **kwargs):
|
||||
return response.Response(serializers.repr_instance_actor())
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def inbox(self, request, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def outbox(self, request, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
renderer_classes = [renderers.WebfingerRenderer]
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def webfinger(self, request, *args, **kwargs):
|
||||
try:
|
||||
resource_type, resource = webfinger.clean_resource(
|
||||
request.GET['resource'])
|
||||
cleaner = getattr(webfinger, 'clean_{}'.format(resource_type))
|
||||
result = cleaner(resource)
|
||||
except forms.ValidationError as e:
|
||||
return response.Response({
|
||||
'errors': {
|
||||
'resource': e.message
|
||||
}
|
||||
}, status=400)
|
||||
except KeyError:
|
||||
return response.Response({
|
||||
'errors': {
|
||||
'resource': 'This field is required',
|
||||
}
|
||||
}, status=400)
|
||||
|
||||
handler = getattr(self, 'handler_{}'.format(resource_type))
|
||||
data = handler(result)
|
||||
|
||||
return response.Response(data)
|
||||
|
||||
def handler_acct(self, clean_result):
|
||||
username, hostname = clean_result
|
||||
if username == 'service':
|
||||
return webfinger.serialize_system_acct()
|
||||
return {}
|
|
@ -0,0 +1,52 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from . import utils
|
||||
VALID_RESOURCE_TYPES = ['acct']
|
||||
|
||||
|
||||
def clean_resource(resource_string):
|
||||
if not resource_string:
|
||||
raise forms.ValidationError('Invalid resource string')
|
||||
|
||||
try:
|
||||
resource_type, resource = resource_string.split(':', 1)
|
||||
except ValueError:
|
||||
raise forms.ValidationError('Missing webfinger resource type')
|
||||
|
||||
if resource_type not in VALID_RESOURCE_TYPES:
|
||||
raise forms.ValidationError('Invalid webfinger resource type')
|
||||
|
||||
return resource_type, resource
|
||||
|
||||
|
||||
def clean_acct(acct_string):
|
||||
try:
|
||||
username, hostname = acct_string.split('@')
|
||||
except ValueError:
|
||||
raise forms.ValidationError('Invalid format')
|
||||
|
||||
if hostname != settings.FEDERATION_HOSTNAME:
|
||||
raise forms.ValidationError('Invalid hostname')
|
||||
|
||||
if username != 'service':
|
||||
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')),
|
||||
}
|
||||
]
|
||||
}
|
|
@ -10,3 +10,4 @@ pytest-mock
|
|||
pytest-sugar
|
||||
pytest-xdist
|
||||
pytest-cov
|
||||
requests-mock
|
||||
|
|
|
@ -2,6 +2,7 @@ import factory
|
|||
import tempfile
|
||||
import shutil
|
||||
import pytest
|
||||
import requests_mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache as django_cache
|
||||
|
@ -148,3 +149,9 @@ def media_root(settings):
|
|||
settings.MEDIA_ROOT = tmp_dir
|
||||
yield settings.MEDIA_ROOT
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def r_mock():
|
||||
with requests_mock.mock() as m:
|
||||
yield m
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
from django.core.management import call_command
|
||||
|
||||
|
||||
def test_generate_instance_key_pair(preferences, mocker):
|
||||
mocker.patch(
|
||||
'funkwhale_api.federation.keys.get_key_pair',
|
||||
return_value=(b'private', b'public'))
|
||||
assert preferences['federation__public_key'] == ''
|
||||
assert preferences['federation__private_key'] == ''
|
||||
|
||||
call_command('generate_keys', interactive=False)
|
||||
|
||||
assert preferences['federation__private_key'] == 'private'
|
||||
assert preferences['federation__public_key'] == 'public'
|
|
@ -0,0 +1,16 @@
|
|||
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)
|
||||
|
||||
assert r['id'] == payload['id']
|
||||
assert r['owner'] == payload['owner']
|
||||
assert r['public_key_pem'] == payload['publicKeyPem']
|
|
@ -4,6 +4,7 @@ import pytest
|
|||
import requests_http_signature
|
||||
|
||||
from funkwhale_api.federation import signing
|
||||
from funkwhale_api.federation import keys
|
||||
|
||||
|
||||
def test_can_sign_and_verify_request(factories):
|
||||
|
@ -45,7 +46,7 @@ def test_verify_fails_with_wrong_key(factories):
|
|||
|
||||
|
||||
def test_can_verify_django_request(factories, api_request):
|
||||
private_key, public_key = signing.get_key_pair()
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key
|
||||
)
|
||||
|
@ -61,7 +62,7 @@ def test_can_verify_django_request(factories, api_request):
|
|||
|
||||
|
||||
def test_can_verify_django_request_digest(factories, api_request):
|
||||
private_key, public_key = signing.get_key_pair()
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key,
|
||||
method='post',
|
||||
|
@ -81,7 +82,7 @@ def test_can_verify_django_request_digest(factories, api_request):
|
|||
|
||||
|
||||
def test_can_verify_django_request_digest_failure(factories, api_request):
|
||||
private_key, public_key = signing.get_key_pair()
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key,
|
||||
method='post',
|
||||
|
@ -102,7 +103,7 @@ def test_can_verify_django_request_digest_failure(factories, api_request):
|
|||
|
||||
|
||||
def test_can_verify_django_request_failure(factories, api_request):
|
||||
private_key, public_key = signing.get_key_pair()
|
||||
private_key, public_key = keys.get_key_pair()
|
||||
signed_request = factories['federation.SignedRequest'](
|
||||
auth__key=private_key
|
||||
)
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from funkwhale_api.federation import utils
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url,path,expected', [
|
||||
('http://test.com', '/hello', 'http://test.com/hello'),
|
||||
('http://test.com/', 'hello', 'http://test.com/hello'),
|
||||
('http://test.com/', '/hello', 'http://test.com/hello'),
|
||||
('http://test.com', 'hello', 'http://test.com/hello'),
|
||||
])
|
||||
def test_full_url(settings, url, path, expected):
|
||||
settings.FUNKWHALE_URL = url
|
||||
assert utils.full_url(path) == expected
|
|
@ -0,0 +1,69 @@
|
|||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
|
||||
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')
|
||||
response = api_client.get(url)
|
||||
assert response.data['id'] == (
|
||||
settings.FUNKWHALE_URL + url
|
||||
)
|
||||
assert response.data['type'] == 'Service'
|
||||
assert response.data['inbox'] == (
|
||||
settings.FUNKWHALE_URL + reverse('federation:instance-inbox')
|
||||
)
|
||||
assert response.data['outbox'] == (
|
||||
settings.FUNKWHALE_URL + reverse('federation:instance-outbox')
|
||||
)
|
||||
assert response.data['@context'] == [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route', [
|
||||
'instance-outbox',
|
||||
'instance-inbox',
|
||||
'instance-actor',
|
||||
'well-known-webfinger',
|
||||
])
|
||||
def test_instance_inbox_405_if_federation_disabled(
|
||||
db, settings, api_client, route):
|
||||
settings.FEDERATION_ENABLED = False
|
||||
url = reverse('federation:{}'.format(route))
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
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'})
|
||||
|
||||
clean.assert_called_once_with('something')
|
||||
assert url == '/.well-known/webfinger'
|
||||
assert response.status_code == 400
|
||||
assert response.data['errors']['resource'] == (
|
||||
'Missing webfinger resource type'
|
||||
)
|
||||
|
||||
|
||||
def test_wellknown_webfinger_system(
|
||||
db, api_client, settings, mocker):
|
||||
settings.FEDERATION_ENABLED = True
|
||||
settings.FEDERATION_HOSTNAME = 'test.federation'
|
||||
url = reverse('federation:well-known-webfinger')
|
||||
response = api_client.get(
|
||||
url, data={'resource': 'acct:service@test.federation'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == 'application/jrd+json'
|
||||
assert response.data == webfinger.serialize_system_acct()
|
|
@ -0,0 +1,66 @@
|
|||
import pytest
|
||||
|
||||
from django import forms
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.federation import webfinger
|
||||
|
||||
|
||||
def test_webfinger_clean_resource():
|
||||
t, r = webfinger.clean_resource('acct:service@test.federation')
|
||||
assert t == 'acct'
|
||||
assert r == 'service@test.federation'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('resource,message', [
|
||||
('', 'Invalid resource string'),
|
||||
('service@test.com', 'Missing webfinger resource type'),
|
||||
('noop:service@test.com', 'Invalid webfinger resource type'),
|
||||
])
|
||||
def test_webfinger_clean_resource_errors(resource, message):
|
||||
with pytest.raises(forms.ValidationError) as excinfo:
|
||||
webfinger.clean_resource(resource)
|
||||
|
||||
assert message == str(excinfo)
|
||||
|
||||
|
||||
def test_webfinger_clean_acct(settings):
|
||||
settings.FEDERATION_HOSTNAME = 'test.federation'
|
||||
username, hostname = webfinger.clean_acct('service@test.federation')
|
||||
assert username == 'service'
|
||||
assert hostname == 'test.federation'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('resource,message', [
|
||||
('service', 'Invalid format'),
|
||||
('service@test.com', 'Invalid hostname'),
|
||||
('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/instance/actor',
|
||||
'type': 'application/activity+json',
|
||||
}
|
||||
],
|
||||
'aliases': [
|
||||
'https://test.federation/instance/actor',
|
||||
]
|
||||
}
|
||||
|
||||
assert expected == webfinger.serialize_system_acct()
|
|
@ -3,8 +3,8 @@ proxy_set_header Host $host;
|
|||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_redirect off;
|
||||
|
||||
# websocket support
|
||||
|
|
|
@ -62,6 +62,16 @@ server {
|
|||
proxy_pass http://funkwhale-api/api/;
|
||||
}
|
||||
|
||||
location /federation/ {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://funkwhale-api/federation/;
|
||||
}
|
||||
|
||||
location /.well-known/webfinger {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://funkwhale-api/.well-known/webfinger;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /srv/funkwhale/data/media/;
|
||||
}
|
||||
|
|
39
dev.yml
39
dev.yml
|
@ -1,27 +1,35 @@
|
|||
version: '2'
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
|
||||
front:
|
||||
build: front
|
||||
env_file: .env.dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
environment:
|
||||
- "HOST=0.0.0.0"
|
||||
- "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "${WEBPACK_DEVSERVER_PORT-8080}:${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
volumes:
|
||||
- './front:/app'
|
||||
|
||||
postgres:
|
||||
env_file: .env.dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
image: postgres
|
||||
|
||||
redis:
|
||||
env_file: .env.dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
image: redis:3.0
|
||||
|
||||
celeryworker:
|
||||
env_file: .env.dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: docker/Dockerfile.test
|
||||
|
@ -36,12 +44,14 @@ services:
|
|||
- C_FORCE_ROOT=true
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
- "FUNKWHALE_URL=http://funkwhale.test"
|
||||
- "FUNKWHALE_URL=http://localhost"
|
||||
volumes:
|
||||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
api:
|
||||
env_file: .env.dev
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: docker/Dockerfile.test
|
||||
|
@ -56,19 +66,26 @@ services:
|
|||
- "DJANGO_SECRET_KEY=dev"
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
- "FUNKWHALE_URL=http://funkwhale.test"
|
||||
- "FUNKWHALE_URL=http://localhost"
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
nginx:
|
||||
env_file: .env.dev
|
||||
command: /entrypoint.sh
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
image: nginx
|
||||
environment:
|
||||
- "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
links:
|
||||
- api
|
||||
- front
|
||||
volumes:
|
||||
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
|
||||
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
|
||||
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
|
||||
- ./api/funkwhale_api/media:/protected/media
|
||||
ports:
|
||||
- "0.0.0.0:6001:6001"
|
||||
|
|
|
@ -37,19 +37,7 @@ http {
|
|||
listen 6001;
|
||||
charset utf-8;
|
||||
client_max_body_size 20M;
|
||||
|
||||
# global proxy pass config
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host localhost:8080;
|
||||
proxy_set_header X-Forwarded-Port 8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_redirect off;
|
||||
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
location /_protected/media {
|
||||
internal;
|
||||
alias /protected/media;
|
||||
|
@ -63,8 +51,7 @@ http {
|
|||
if ($request_uri ~* "[^\?]+\?(.*)$") {
|
||||
set $query $1;
|
||||
}
|
||||
proxy_set_header X-Forwarded-Host localhost:8080;
|
||||
proxy_set_header X-Forwarded-Port 8080;
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
|
@ -78,6 +65,7 @@ http {
|
|||
if ($args ~ (.*)jwt=[^&]*(.*)) {
|
||||
set $cleaned_args $1$2;
|
||||
}
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args";
|
||||
proxy_cache transcode;
|
||||
proxy_cache_valid 200 7d;
|
||||
|
@ -87,6 +75,7 @@ http {
|
|||
proxy_pass http://api:12081;
|
||||
}
|
||||
location / {
|
||||
include /etc/nginx/funkwhale_proxy.conf;
|
||||
proxy_pass http://api:12081/;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash -eux
|
||||
|
||||
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/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
|
||||
nginx -g "daemon off;"
|
|
@ -23,25 +23,37 @@ module.exports = {
|
|||
},
|
||||
dev: {
|
||||
env: require('./dev.env'),
|
||||
port: 8080,
|
||||
port: parseInt(process.env.WEBPACK_DEVSERVER_PORT),
|
||||
host: '127.0.0.1',
|
||||
autoOpenBrowser: true,
|
||||
assetsSubDirectory: 'static',
|
||||
assetsPublicPath: '/',
|
||||
proxyTable: {
|
||||
'/api': {
|
||||
'**': {
|
||||
target: 'http://nginx:6001',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
ws: true,
|
||||
filter: function (pathname, req) {
|
||||
let proxified = ['.well-known', 'staticfiles', 'media', 'instance', 'api']
|
||||
let matches = proxified.filter(e => {
|
||||
return pathname.match(`^/${e}`)
|
||||
})
|
||||
return matches.length > 0
|
||||
}
|
||||
},
|
||||
'/media': {
|
||||
target: 'http://nginx:6001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/staticfiles': {
|
||||
target: 'http://nginx:6001',
|
||||
changeOrigin: true,
|
||||
}
|
||||
// '/.well-known': {
|
||||
// target: 'http://nginx:6001',
|
||||
// changeOrigin: true
|
||||
// },
|
||||
// '/media': {
|
||||
// target: 'http://nginx:6001',
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
// '/staticfiles': {
|
||||
// target: 'http://nginx:6001',
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
|
||||
},
|
||||
// CSS Sourcemaps off by default because relative paths are "buggy"
|
||||
// with this option, according to the CSS-Loader README
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
"autoprefixer": "^6.7.2",
|
||||
"babel-core": "^6.22.1",
|
||||
"babel-eslint": "^7.1.1",
|
||||
"babel-loader": "^6.2.10",
|
||||
"babel-loader": "7",
|
||||
"babel-plugin-istanbul": "^4.1.1",
|
||||
"babel-plugin-transform-runtime": "^6.22.0",
|
||||
"babel-preset-env": "^1.3.2",
|
||||
|
@ -101,7 +101,7 @@
|
|||
"vue-loader": "^12.1.0",
|
||||
"vue-style-loader": "^3.0.1",
|
||||
"vue-template-compiler": "^2.3.3",
|
||||
"webpack": "^2.6.1",
|
||||
"webpack": "3",
|
||||
"webpack-bundle-analyzer": "^2.2.1",
|
||||
"webpack-dev-middleware": "^1.10.0",
|
||||
"webpack-hot-middleware": "^2.18.0",
|
||||
|
|
Loading…
Reference in New Issue