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
|
docs/_build
|
||||||
|
|
||||||
data/
|
data/
|
||||||
|
.env
|
||||||
|
|
47
CHANGELOG
47
CHANGELOG
|
@ -3,6 +3,53 @@ Changelog
|
||||||
|
|
||||||
.. towncrier
|
.. 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)
|
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
|
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
|
Database management
|
||||||
^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
import os
|
import os
|
||||||
import environ
|
import environ
|
||||||
from funkwhale_api import __version__
|
from funkwhale_api import __version__
|
||||||
|
@ -24,8 +25,13 @@ try:
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
|
||||||
FUNKWHALE_URL = env('FUNKWHALE_URL')
|
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
|
# APP CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
@ -395,4 +401,5 @@ ACCOUNT_USERNAME_BLACKLIST = [
|
||||||
'owner',
|
'owner',
|
||||||
'superuser',
|
'superuser',
|
||||||
'staff',
|
'staff',
|
||||||
|
'service',
|
||||||
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
|
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
|
||||||
|
|
|
@ -13,6 +13,9 @@ urlpatterns = [
|
||||||
url(settings.ADMIN_URL, admin.site.urls),
|
url(settings.ADMIN_URL, admin.site.urls),
|
||||||
|
|
||||||
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
|
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/', include('rest_auth.urls')),
|
||||||
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
|
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
|
||||||
url(r'^accounts/', include('allauth.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 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')
|
@registry.register(name='federation.SignatureAuth')
|
||||||
class SignatureAuthFactory(factory.Factory):
|
class SignatureAuthFactory(factory.Factory):
|
||||||
algorithm = 'rsa-sha256'
|
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')
|
key_id = factory.Faker('url')
|
||||||
|
|
||||||
class Meta:
|
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
|
||||||
import requests_http_signature
|
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):
|
def verify(request, public_key):
|
||||||
return requests_http_signature.HTTPSignatureAuth.verify(
|
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-sugar
|
||||||
pytest-xdist
|
pytest-xdist
|
||||||
pytest-cov
|
pytest-cov
|
||||||
|
requests-mock
|
||||||
|
|
|
@ -2,6 +2,7 @@ import factory
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests_mock
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache as django_cache
|
from django.core.cache import cache as django_cache
|
||||||
|
@ -148,3 +149,9 @@ def media_root(settings):
|
||||||
settings.MEDIA_ROOT = tmp_dir
|
settings.MEDIA_ROOT = tmp_dir
|
||||||
yield settings.MEDIA_ROOT
|
yield settings.MEDIA_ROOT
|
||||||
shutil.rmtree(tmp_dir)
|
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
|
import requests_http_signature
|
||||||
|
|
||||||
from funkwhale_api.federation import signing
|
from funkwhale_api.federation import signing
|
||||||
|
from funkwhale_api.federation import keys
|
||||||
|
|
||||||
|
|
||||||
def test_can_sign_and_verify_request(factories):
|
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):
|
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'](
|
signed_request = factories['federation.SignedRequest'](
|
||||||
auth__key=private_key
|
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):
|
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'](
|
signed_request = factories['federation.SignedRequest'](
|
||||||
auth__key=private_key,
|
auth__key=private_key,
|
||||||
method='post',
|
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):
|
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'](
|
signed_request = factories['federation.SignedRequest'](
|
||||||
auth__key=private_key,
|
auth__key=private_key,
|
||||||
method='post',
|
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):
|
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'](
|
signed_request = factories['federation.SignedRequest'](
|
||||||
auth__key=private_key
|
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()
|
|
@ -62,6 +62,16 @@ server {
|
||||||
proxy_pass http://funkwhale-api/api/;
|
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/ {
|
location /media/ {
|
||||||
alias /srv/funkwhale/data/media/;
|
alias /srv/funkwhale/data/media/;
|
||||||
}
|
}
|
||||||
|
|
39
dev.yml
39
dev.yml
|
@ -1,27 +1,35 @@
|
||||||
version: '2'
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
front:
|
front:
|
||||||
build: front
|
build: front
|
||||||
env_file: .env.dev
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- "HOST=0.0.0.0"
|
- "HOST=0.0.0.0"
|
||||||
|
- "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "${WEBPACK_DEVSERVER_PORT-8080}:${WEBPACK_DEVSERVER_PORT-8080}"
|
||||||
volumes:
|
volumes:
|
||||||
- './front:/app'
|
- './front:/app'
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
env_file: .env.dev
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
- .env
|
||||||
image: postgres
|
image: postgres
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
env_file: .env.dev
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
- .env
|
||||||
image: redis:3.0
|
image: redis:3.0
|
||||||
|
|
||||||
celeryworker:
|
celeryworker:
|
||||||
env_file: .env.dev
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
- .env
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
dockerfile: docker/Dockerfile.test
|
dockerfile: docker/Dockerfile.test
|
||||||
|
@ -36,12 +44,14 @@ services:
|
||||||
- C_FORCE_ROOT=true
|
- C_FORCE_ROOT=true
|
||||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||||
- "CACHE_URL=redis://redis:6379/0"
|
- "CACHE_URL=redis://redis:6379/0"
|
||||||
- "FUNKWHALE_URL=http://funkwhale.test"
|
- "FUNKWHALE_URL=http://localhost"
|
||||||
volumes:
|
volumes:
|
||||||
- ./api:/app
|
- ./api:/app
|
||||||
- ./data/music:/music
|
- ./data/music:/music
|
||||||
api:
|
api:
|
||||||
env_file: .env.dev
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
- .env
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
dockerfile: docker/Dockerfile.test
|
dockerfile: docker/Dockerfile.test
|
||||||
|
@ -56,19 +66,26 @@ services:
|
||||||
- "DJANGO_SECRET_KEY=dev"
|
- "DJANGO_SECRET_KEY=dev"
|
||||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||||
- "CACHE_URL=redis://redis:6379/0"
|
- "CACHE_URL=redis://redis:6379/0"
|
||||||
- "FUNKWHALE_URL=http://funkwhale.test"
|
- "FUNKWHALE_URL=http://localhost"
|
||||||
links:
|
links:
|
||||||
- postgres
|
- postgres
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
env_file: .env.dev
|
command: /entrypoint.sh
|
||||||
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
- .env
|
||||||
image: nginx
|
image: nginx
|
||||||
|
environment:
|
||||||
|
- "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
|
||||||
links:
|
links:
|
||||||
- api
|
- api
|
||||||
- front
|
- front
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
|
- ./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
|
- ./api/funkwhale_api/media:/protected/media
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:6001:6001"
|
- "0.0.0.0:6001:6001"
|
||||||
|
|
|
@ -37,19 +37,7 @@ http {
|
||||||
listen 6001;
|
listen 6001;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
client_max_body_size 20M;
|
client_max_body_size 20M;
|
||||||
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
# 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;
|
|
||||||
|
|
||||||
location /_protected/media {
|
location /_protected/media {
|
||||||
internal;
|
internal;
|
||||||
alias /protected/media;
|
alias /protected/media;
|
||||||
|
@ -63,8 +51,7 @@ http {
|
||||||
if ($request_uri ~* "[^\?]+\?(.*)$") {
|
if ($request_uri ~* "[^\?]+\?(.*)$") {
|
||||||
set $query $1;
|
set $query $1;
|
||||||
}
|
}
|
||||||
proxy_set_header X-Forwarded-Host localhost:8080;
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
proxy_set_header X-Forwarded-Port 8080;
|
|
||||||
proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
|
proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
|
||||||
proxy_pass_request_body off;
|
proxy_pass_request_body off;
|
||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
|
@ -78,6 +65,7 @@ http {
|
||||||
if ($args ~ (.*)jwt=[^&]*(.*)) {
|
if ($args ~ (.*)jwt=[^&]*(.*)) {
|
||||||
set $cleaned_args $1$2;
|
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_key "$scheme$request_method$host$uri$is_args$cleaned_args";
|
||||||
proxy_cache transcode;
|
proxy_cache transcode;
|
||||||
proxy_cache_valid 200 7d;
|
proxy_cache_valid 200 7d;
|
||||||
|
@ -87,6 +75,7 @@ http {
|
||||||
proxy_pass http://api:12081;
|
proxy_pass http://api:12081;
|
||||||
}
|
}
|
||||||
location / {
|
location / {
|
||||||
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
proxy_pass http://api:12081/;
|
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,26 +23,38 @@ module.exports = {
|
||||||
},
|
},
|
||||||
dev: {
|
dev: {
|
||||||
env: require('./dev.env'),
|
env: require('./dev.env'),
|
||||||
port: 8080,
|
port: parseInt(process.env.WEBPACK_DEVSERVER_PORT),
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
autoOpenBrowser: true,
|
autoOpenBrowser: true,
|
||||||
assetsSubDirectory: 'static',
|
assetsSubDirectory: 'static',
|
||||||
assetsPublicPath: '/',
|
assetsPublicPath: '/',
|
||||||
proxyTable: {
|
proxyTable: {
|
||||||
'/api': {
|
'**': {
|
||||||
target: 'http://nginx:6001',
|
|
||||||
changeOrigin: true,
|
|
||||||
ws: true
|
|
||||||
},
|
|
||||||
'/media': {
|
|
||||||
target: 'http://nginx:6001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/staticfiles': {
|
|
||||||
target: 'http://nginx:6001',
|
target: 'http://nginx:6001',
|
||||||
changeOrigin: true,
|
changeOrigin: 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// '/.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"
|
// CSS Sourcemaps off by default because relative paths are "buggy"
|
||||||
// with this option, according to the CSS-Loader README
|
// with this option, according to the CSS-Loader README
|
||||||
// (https://github.com/webpack/css-loader#sourcemaps)
|
// (https://github.com/webpack/css-loader#sourcemaps)
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
"autoprefixer": "^6.7.2",
|
"autoprefixer": "^6.7.2",
|
||||||
"babel-core": "^6.22.1",
|
"babel-core": "^6.22.1",
|
||||||
"babel-eslint": "^7.1.1",
|
"babel-eslint": "^7.1.1",
|
||||||
"babel-loader": "^6.2.10",
|
"babel-loader": "7",
|
||||||
"babel-plugin-istanbul": "^4.1.1",
|
"babel-plugin-istanbul": "^4.1.1",
|
||||||
"babel-plugin-transform-runtime": "^6.22.0",
|
"babel-plugin-transform-runtime": "^6.22.0",
|
||||||
"babel-preset-env": "^1.3.2",
|
"babel-preset-env": "^1.3.2",
|
||||||
|
@ -101,7 +101,7 @@
|
||||||
"vue-loader": "^12.1.0",
|
"vue-loader": "^12.1.0",
|
||||||
"vue-style-loader": "^3.0.1",
|
"vue-style-loader": "^3.0.1",
|
||||||
"vue-template-compiler": "^2.3.3",
|
"vue-template-compiler": "^2.3.3",
|
||||||
"webpack": "^2.6.1",
|
"webpack": "3",
|
||||||
"webpack-bundle-analyzer": "^2.2.1",
|
"webpack-bundle-analyzer": "^2.2.1",
|
||||||
"webpack-dev-middleware": "^1.10.0",
|
"webpack-dev-middleware": "^1.10.0",
|
||||||
"webpack-hot-middleware": "^2.18.0",
|
"webpack-hot-middleware": "^2.18.0",
|
||||||
|
|
Loading…
Reference in New Issue