diff --git a/.env.dev b/.env.dev index d9e2dd3ceb8c2625874cb4f7f902be5ab9c33320..e117dbe562c0fc536686a896994b62f96111d7d5 100644 --- a/.env.dev +++ b/.env.dev @@ -9,3 +9,5 @@ FUNKWHALE_PROTOCOL=http PYTHONDONTWRITEBYTECODE=true WEBPACK_DEVSERVER_PORT=8080 MUSIC_DIRECTORY_PATH=/music +BROWSABLE_API_ENABLED=True +CACHEOPS_ENABLED=False diff --git a/CHANGELOG b/CHANGELOG index ba9b9f1ae0dbfac417fdb857eb46fbdb7718d744..c53714488a7a2050cc7cb09c0594ccbe4d9b02dd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,127 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog. .. towncrier +0.13 (2018-05-19) +----------------- + +Upgrade instructions are available at + https://docs.funkwhale.audio/upgrading.html + +Features: + +- Can now import and play flac files (#157) +- Simpler permission system (#152) +- Store file length, size and bitrate (#195) +- We now have a brand new instance settings interface in the front-end (#206) + + +Enhancements: + +- Disabled browsable HTML API in production (#205) +- Instances can now indicate on the nodeinfo endpoint if they want to remain + private (#200) + + +Bugfixes: + +- .well-known/nodeinfo endpoint can now answer to request with Accept: + application/json (#197) +- Fixed escaping issue of track name in playlist modal (#201) +- Fixed missing dot when downloading file (#204) +- In-place imported tracks with non-ascii characters don't break reverse-proxy + serving (#196) +- Removed Python 3.6 dependency (secrets module) (#198) +- Uplayable tracks are now properly disabled in the interface (#199) + + +Instance settings interface +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Prior to this release, the only way to update instance settings (such as +instance description, signup policy, federation configuration, etc.) was using +the admin interface provided by Django (the back-end framework which power the API). + +This interface worked, but was not really-user friendly and intuitive. + +Starting from this release, we now offer a dedicated interface directly +in the front-end. You can view and edit all your instance settings from here, +assuming you have the required permissions. + +This interface is available at ``/manage/settings` and via link in the sidebar. + + +Storage of bitrate, size and length in database +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Starting with this release, when importing files, Funkwhale will store +additional information about audio files: + +- Bitrate +- Size (in bytes) +- Duration + +This change is not retroactive, meaning already imported files will lack those +informations. The interface and API should work as before in such case, however, +we offer a command to deal with legacy files and populate the missing values. + +On docker setups: + +.. code-block:: shell + + docker-compose run --rm api python manage.py fix_track_files + + +On non-docker setups: + +.. code-block:: shell + + # from your activated virtualenv + python manage.py fix_track_files + +.. note:: + + The execution time for this command is proportional to the number of + audio files stored on your instance. This is because we need to read the + files from disk to fetch the data. You can run it in the background + while Funkwhale is up. + + It's also safe to interrupt this command and rerun it at a later point, or run + it multiple times. + + Use the --dry-run flag to check how many files would be impacted. + + +Simpler permission system +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Starting from this release, the permission system is much simpler. Up until now, +we were using Django's built-in permission system, which was working, but also +quite complex to deal with. + +The new implementation relies on simpler logic, which will make integration +on the front-end in upcoming releases faster and easier. + +If you have manually given permissions to users on your instance, +you can migrate those to the new system. + +On docker setups: + +.. code-block:: shell + + docker-compose run --rm api python manage.py script django_permissions_to_user_permissions --no-input + +On non-docker setups: + +.. code-block:: shell + + # in your virtualenv + python api/manage.py script django_permissions_to_user_permissions --no-input + +There is still no dedicated interface to manage user permissions, but you +can use the admin interface at ``/api/admin/users/user/`` for that purpose in +the meantime. + + 0.12 (2018-05-09) ----------------- @@ -110,9 +231,7 @@ We offer two settings to manage nodeinfo in your Funkwhale instance: and user activity. To make your instance fully compatible with the nodeinfo protocol, you need to -to edit your nginx configuration file: - -.. code-block:: +to edit your nginx configuration file:: # before ... @@ -130,9 +249,7 @@ to edit your nginx configuration file: } ... -You can do the same if you use apache: - -.. code-block:: +You can do the same if you use apache:: # before ... diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 5fed9f25e86d065aff3b8f4ab662d38c65870aaf..59aa93117e503b08f8a2fa53df8282b4ec0bbf32 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -406,8 +406,18 @@ REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework.filters.OrderingFilter', 'django_filters.rest_framework.DjangoFilterBackend', + ), + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', ) } + +BROWSABLE_API_ENABLED = env.bool('BROWSABLE_API_ENABLED', default=False) +if BROWSABLE_API_ENABLED: + REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] += ( + 'rest_framework.renderers.BrowsableAPIRenderer', + ) + REST_AUTH_SERIALIZERS = { 'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa } diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index f8b8af4126f34f3e55477c22d0c3a985ad827763..b0d7cc5173495755f7774850bcf4ccd0480133fe 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = '0.12' +__version__ = '0.13' __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) diff --git a/api/funkwhale_api/common/dynamic_preferences_registry.py b/api/funkwhale_api/common/dynamic_preferences_registry.py index 2374de7c79fe841ae63d8c18354335cf6fd022b2..15b182671bbf386b401eeb541c3839889342e29d 100644 --- a/api/funkwhale_api/common/dynamic_preferences_registry.py +++ b/api/funkwhale_api/common/dynamic_preferences_registry.py @@ -16,5 +16,5 @@ class APIAutenticationRequired( help_text = ( 'If disabled, anonymous users will be able to query the API' 'and access music data (as well as other data exposed in the API ' - 'without specific permissions)' + 'without specific permissions).' ) diff --git a/api/funkwhale_api/common/management/__init__.py b/api/funkwhale_api/common/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/common/management/commands/__init__.py b/api/funkwhale_api/common/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/common/management/commands/script.py b/api/funkwhale_api/common/management/commands/script.py new file mode 100644 index 0000000000000000000000000000000000000000..9d26a5836d46906967e24662a94bab553fa15880 --- /dev/null +++ b/api/funkwhale_api/common/management/commands/script.py @@ -0,0 +1,66 @@ +from django.core.management.base import BaseCommand, CommandError + +from funkwhale_api.common import scripts + + +class Command(BaseCommand): + help = 'Run a specific script from funkwhale_api/common/scripts/' + + def add_arguments(self, parser): + parser.add_argument('script_name', nargs='?', type=str) + parser.add_argument( + '--noinput', '--no-input', action='store_false', dest='interactive', + help="Do NOT prompt the user for input of any kind.", + ) + + def handle(self, *args, **options): + name = options['script_name'] + if not name: + self.show_help() + + available_scripts = self.get_scripts() + try: + script = available_scripts[name] + except KeyError: + raise CommandError( + '{} is not a valid script. Run python manage.py script for a ' + 'list of available scripts'.format(name)) + + self.stdout.write('') + if options['interactive']: + message = ( + 'Are you sure you want to execute the script {}?\n\n' + "Type 'yes' to continue, or 'no' to cancel: " + ).format(name) + if input(''.join(message)) != 'yes': + raise CommandError("Script cancelled.") + script['entrypoint'](self, **options) + + def show_help(self): + indentation = 4 + self.stdout.write('') + self.stdout.write('Available scripts:') + self.stdout.write('Launch with: python manage.py <script_name>') + available_scripts = self.get_scripts() + for name, script in sorted(available_scripts.items()): + self.stdout.write('') + self.stdout.write(self.style.SUCCESS(name)) + self.stdout.write('') + for line in script['help'].splitlines(): + self.stdout.write('    {}'.format(line)) + self.stdout.write('') + + def get_scripts(self): + available_scripts = [ + k for k in sorted(scripts.__dict__.keys()) + if not k.startswith('__') + ] + data = {} + for name in available_scripts: + module = getattr(scripts, name) + data[name] = { + 'name': name, + 'help': module.__doc__.strip(), + 'entrypoint': module.main + } + return data diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py index cab4b699d2a518cffc52aaf0cbad4473dcc28a6f..e9e8b8819f4b70e9ada3a0bcf71c709c9a5ef1de 100644 --- a/api/funkwhale_api/common/permissions.py +++ b/api/funkwhale_api/common/permissions.py @@ -3,7 +3,7 @@ import operator from django.conf import settings from django.http import Http404 -from rest_framework.permissions import BasePermission, DjangoModelPermissions +from rest_framework.permissions import BasePermission from funkwhale_api.common import preferences @@ -16,17 +16,6 @@ class ConditionalAuthentication(BasePermission): return True -class HasModelPermission(DjangoModelPermissions): - """ - Same as DjangoModelPermissions, but we pin the model: - - class MyModelPermission(HasModelPermission): - model = User - """ - def get_required_permissions(self, method, model_cls): - return super().get_required_permissions(method, self.model) - - class OwnerPermission(BasePermission): """ Ensure the request user is the owner of the object. diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4b2d525202218c43483dae60b6df4f4c6090723e --- /dev/null +++ b/api/funkwhale_api/common/scripts/__init__.py @@ -0,0 +1,2 @@ +from . import django_permissions_to_user_permissions +from . import test diff --git a/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..1bc971f80911de276ef2c210fafddfc052d71ee6 --- /dev/null +++ b/api/funkwhale_api/common/scripts/django_permissions_to_user_permissions.py @@ -0,0 +1,29 @@ +""" +Convert django permissions to user permissions in the database, +following the work done in #152. +""" +from django.db.models import Q +from funkwhale_api.users import models + +from django.contrib.auth.models import Permission + +mapping = { + 'dynamic_preferences.change_globalpreferencemodel': 'settings', + 'music.add_importbatch': 'library', + 'federation.change_library': 'federation', +} + + +def main(command, **kwargs): + for codename, user_permission in sorted(mapping.items()): + app_label, c = codename.split('.') + p = Permission.objects.get( + content_type__app_label=app_label, codename=c) + users = models.User.objects.filter( + Q(groups__permissions=p) | Q(user_permissions=p)).distinct() + total = users.count() + + command.stdout.write('Updating {} users with {} permission...'.format( + total, user_permission + )) + users.update(**{'permission_{}'.format(user_permission): True}) diff --git a/api/funkwhale_api/common/scripts/test.py b/api/funkwhale_api/common/scripts/test.py new file mode 100644 index 0000000000000000000000000000000000000000..ab401dca4a7a46be53df6ffc1a09cd6657407ea8 --- /dev/null +++ b/api/funkwhale_api/common/scripts/test.py @@ -0,0 +1,8 @@ +""" +This is a test script that does nothing. +You can launch it just to check how it works. +""" + + +def main(command, **kwargs): + command.stdout.write('Test script run successfully') diff --git a/api/funkwhale_api/federation/dynamic_preferences_registry.py b/api/funkwhale_api/federation/dynamic_preferences_registry.py index e86b9f6f2b8b30da1c82145ef288e591449aa9f3..8b1b2b03f9eb00f0eac1d467c099baf66d72d67d 100644 --- a/api/funkwhale_api/federation/dynamic_preferences_registry.py +++ b/api/funkwhale_api/federation/dynamic_preferences_registry.py @@ -19,6 +19,9 @@ class MusicCacheDuration(types.IntPreference): 'locally? Federated files that were not listened in this interval ' 'will be erased and refetched from the remote on the next listening.' ) + field_kwargs = { + 'required': False, + } @global_preferences_registry.register @@ -29,7 +32,7 @@ class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference): verbose_name = 'Federation enabled' help_text = ( 'Use this setting to enable or disable federation logic and API' - ' globally' + ' globally.' ) @@ -41,8 +44,11 @@ class CollectionPageSize( setting = 'FEDERATION_COLLECTION_PAGE_SIZE' verbose_name = 'Federation collection page size' help_text = ( - 'How much items to display in ActivityPub collections' + 'How much items to display in ActivityPub collections.' ) + field_kwargs = { + 'required': False, + } @global_preferences_registry.register @@ -54,8 +60,11 @@ class ActorFetchDelay( verbose_name = 'Federation actor fetch delay' help_text = ( 'How much minutes to wait before refetching actors on ' - 'request authentication' + 'request authentication.' ) + field_kwargs = { + 'required': False, + } @global_preferences_registry.register @@ -66,6 +75,6 @@ class MusicNeedsApproval( setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL' verbose_name = 'Federation music needs approval' help_text = ( - 'When true, other federation actors will require your approval' + 'When true, other federation actors will need your approval' ' before being able to browse your library.' ) diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py index 0754c4b2f44123e743f9df503c4457b529a17584..891609cbaa30117bbe0eccf802405a89c56b4914 100644 --- a/api/funkwhale_api/federation/factories.py +++ b/api/funkwhale_api/federation/factories.py @@ -233,6 +233,9 @@ class AudioMetadataFactory(factory.Factory): release = factory.LazyAttribute( lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4()) ) + bitrate = 42 + length = 43 + size = 44 class Meta: model = dict diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index d91a00c8b50f5c103fc818f9dea47f6a55cbf9cf..69d0ea9254e7f28c7d54eb683b986a7b9d7b2033 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -216,3 +216,6 @@ class LibraryTrack(models.Model): for chunk in r.iter_content(chunk_size=512): tmp_file.write(chunk) self.audio_file.save(filename, tmp_file) + + def get_metadata(self, key): + return self.metadata.get(key) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 426aabd771b1e5caaaa683648a123ccbe00aa986..8d3dd6379b2f327cfb9b11821b02d34527518171 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -688,6 +688,12 @@ class AudioMetadataSerializer(serializers.Serializer): artist = ArtistMetadataSerializer() release = ReleaseMetadataSerializer() recording = RecordingMetadataSerializer() + bitrate = serializers.IntegerField( + required=False, allow_null=True, min_value=0) + size = serializers.IntegerField( + required=False, allow_null=True, min_value=0) + length = serializers.IntegerField( + required=False, allow_null=True, min_value=0) class AudioSerializer(serializers.Serializer): @@ -760,6 +766,9 @@ class AudioSerializer(serializers.Serializer): 'musicbrainz_id': str(track.mbid) if track.mbid else None, 'title': track.title, }, + 'bitrate': instance.bitrate, + 'size': instance.size, + 'length': instance.duration, }, 'url': { 'href': utils.full_url(instance.path), diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index ef581408c2e187bf32f3922c651b903f3583884e..06a2cd040cfc0d94fe51ab419bf6159c6e14f631 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -15,8 +15,8 @@ from rest_framework.serializers import ValidationError from funkwhale_api.common import preferences from funkwhale_api.common import utils as funkwhale_utils -from funkwhale_api.common.permissions import HasModelPermission from funkwhale_api.music.models import TrackFile +from funkwhale_api.users.permissions import HasUserPermission from . import activity from . import actors @@ -88,7 +88,7 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): class WellKnownViewSet(viewsets.GenericViewSet): authentication_classes = [] permission_classes = [] - renderer_classes = [renderers.WebfingerRenderer] + renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer] @list_route(methods=['get']) def nodeinfo(self, request, *args, **kwargs): @@ -187,16 +187,13 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): return response.Response(data) -class LibraryPermission(HasModelPermission): - model = models.Library - - class LibraryViewSet( mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - permission_classes = [LibraryPermission] + permission_classes = (HasUserPermission,) + required_permissions = ['federation'] queryset = models.Library.objects.all().select_related( 'actor', 'follow', @@ -291,7 +288,8 @@ class LibraryViewSet( class LibraryTrackViewSet( mixins.ListModelMixin, viewsets.GenericViewSet): - permission_classes = [LibraryPermission] + permission_classes = (HasUserPermission,) + required_permissions = ['federation'] queryset = models.LibraryTrack.objects.all().select_related( 'library__actor', 'library__follow', diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py index 03555b0be57bde18f4fe13ba1b62d852ffd93ae4..8ccf80dd93448b4f2512ec2109cda32beaa33bf7 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -13,8 +13,11 @@ class InstanceName(types.StringPreference): section = instance name = 'name' default = '' - help_text = 'Instance public name' - verbose_name = 'The public name of your instance' + verbose_name = 'Public name' + help_text = 'The public name of your instance, displayed in the about page.' + field_kwargs = { + 'required': False, + } @global_preferences_registry.register @@ -23,7 +26,11 @@ class InstanceShortDescription(types.StringPreference): section = instance name = 'short_description' default = '' - verbose_name = 'Instance succinct description' + verbose_name = 'Short description' + help_text = 'Instance succinct description, displayed in the about page.' + field_kwargs = { + 'required': False, + } @global_preferences_registry.register @@ -31,31 +38,31 @@ class InstanceLongDescription(types.StringPreference): show_in_api = True section = instance name = 'long_description' + verbose_name = 'Long description' default = '' - help_text = 'Instance long description (markdown allowed)' + help_text = 'Instance long description, displayed in the about page (markdown allowed).' + widget = widgets.Textarea field_kwargs = { - 'widget': widgets.Textarea + 'required': False, } + @global_preferences_registry.register class RavenDSN(types.StringPreference): show_in_api = True section = raven name = 'front_dsn' default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4' - verbose_name = ( - 'A raven DSN key used to report front-ent errors to ' - 'a sentry instance' - ) + verbose_name = 'Raven DSN key (front-end)' + help_text = ( - 'Keeping the default one will report errors to funkwhale developers' + 'A Raven DSN key used to report front-ent errors to ' + 'a sentry instance. Keeping the default one will report errors to ' + 'Funkwhale developers.' ) - - -SENTRY_HELP_TEXT = ( - 'Error reporting is disabled by default but you can enable it if' - ' you want to help us improve funkwhale' -) + field_kwargs = { + 'required': False, + } @global_preferences_registry.register @@ -65,8 +72,7 @@ class RavenEnabled(types.BooleanPreference): name = 'front_enabled' default = False verbose_name = ( - 'Wether error reporting to a Sentry instance using raven is enabled' - ' for front-end errors' + 'Report front-end errors with Raven' ) @@ -78,13 +84,27 @@ class InstanceNodeinfoEnabled(types.BooleanPreference): default = True verbose_name = 'Enable nodeinfo endpoint' help_text = ( - 'This endpoint is needed for your about page to work.' + 'This endpoint is needed for your about page to work. ' 'It\'s also helpful for the various monitoring ' 'tools that map and analyzize the fediverse, ' 'but you can disable it completely if needed.' ) +@global_preferences_registry.register +class InstanceNodeinfoPrivate(types.BooleanPreference): + show_in_api = False + section = instance + name = 'nodeinfo_private' + default = False + verbose_name = 'Private mode in nodeinfo' + help_text = ( + 'Indicate in the nodeinfo endpoint that you do not want your instance ' + 'to be tracked by third-party services. ' + 'There is no guarantee these tools will honor this setting though.' + ) + + @global_preferences_registry.register class InstanceNodeinfoStatsEnabled(types.BooleanPreference): show_in_api = False @@ -93,6 +113,6 @@ class InstanceNodeinfoStatsEnabled(types.BooleanPreference): default = True verbose_name = 'Enable usage and library stats in nodeinfo endpoint' help_text = ( - 'Disable this f you don\'t want to share usage and library statistics' + 'Disable this if you don\'t want to share usage and library statistics ' 'in the nodeinfo endpoint but don\'t want to disable it completely.' ) diff --git a/api/funkwhale_api/instance/nodeinfo.py b/api/funkwhale_api/instance/nodeinfo.py index e267f197d17ce7c317128c684f1db8f72a1a47ba..dbc005af7b965b55a62abf7f47eb099174a88eac 100644 --- a/api/funkwhale_api/instance/nodeinfo.py +++ b/api/funkwhale_api/instance/nodeinfo.py @@ -12,6 +12,7 @@ memo = memoize.Memoizer(store, namespace='instance:stats') def get(): share_stats = preferences.get('instance__nodeinfo_stats_enabled') + private = preferences.get('instance__nodeinfo_private') data = { 'version': '2.0', 'software': { @@ -30,6 +31,7 @@ def get(): } }, 'metadata': { + 'private': preferences.get('instance__nodeinfo_private'), 'shortDescription': preferences.get('instance__short_description'), 'longDescription': preferences.get('instance__long_description'), 'nodeName': preferences.get('instance__name'), diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py index f506488fc4db7819da6aae5d5c79fe33e8a9af5c..7992842c030c636057ed13236da282febda9cc4e 100644 --- a/api/funkwhale_api/instance/urls.py +++ b/api/funkwhale_api/instance/urls.py @@ -1,9 +1,11 @@ from django.conf.urls import url +from rest_framework import routers from . import views - +admin_router = routers.SimpleRouter() +admin_router.register(r'admin/settings', views.AdminSettings, 'admin-settings') urlpatterns = [ url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'), url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'), -] +] + admin_router.urls diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index 5953ca555a3081d5e58a1d60da1d3dec58279e3b..b905acd3e6c1ce78811e44f691b17506b041ab6f 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -2,9 +2,11 @@ from rest_framework import views from rest_framework.response import Response from dynamic_preferences.api import serializers +from dynamic_preferences.api import viewsets as preferences_viewsets from dynamic_preferences.registries import global_preferences_registry from funkwhale_api.common import preferences +from funkwhale_api.users.permissions import HasUserPermission from . import nodeinfo from . import stats @@ -15,6 +17,11 @@ NODEINFO_2_CONTENT_TYPE = ( ) +class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet): + pagination_class = None + permission_classes = (HasUserPermission,) + required_permissions = ['settings'] + class InstanceSettings(views.APIView): permission_classes = [] authentication_classes = [] diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py index 219b40a91deeeb9d06dd55d3292aa5ca22ccc654..1654428baf866df98f9888c6b101cce2425702cd 100644 --- a/api/funkwhale_api/music/admin.py +++ b/api/funkwhale_api/music/admin.py @@ -38,6 +38,7 @@ class ImportBatchAdmin(admin.ModelAdmin): search_fields = [ 'import_request__name', 'source', 'batch__pk', 'mbid'] + @admin.register(models.ImportJob) class ImportJobAdmin(admin.ModelAdmin): list_display = ['source', 'batch', 'track_file', 'status', 'mbid'] @@ -73,9 +74,16 @@ class TrackFileAdmin(admin.ModelAdmin): 'source', 'duration', 'mimetype', + 'size', + 'bitrate' ] list_select_related = [ 'track' ] - search_fields = ['source', 'acoustid_track_id'] + search_fields = [ + 'source', + 'acoustid_track_id', + 'track__title', + 'track__album__title', + 'track__artist__name'] list_filter = ['mimetype'] diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 1df949904db992e2cd295d02e4acbfed9d212627..412e2f798835579217f6fa84b35e926d59baaba9 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -54,6 +54,10 @@ class TrackFileFactory(factory.django.DjangoModelFactory): audio_file = factory.django.FileField( from_path=os.path.join(SAMPLES_PATH, 'test.ogg')) + bitrate = None + size = None + duration = None + class Meta: model = 'music.TrackFile' diff --git a/api/funkwhale_api/music/management/commands/fix_track_files.py b/api/funkwhale_api/music/management/commands/fix_track_files.py index f68bcf1359d4661710a98ee443ff1578824f46f2..9adc1b9bf1a9d24f3cb26b6da4dce68dc6078213 100644 --- a/api/funkwhale_api/music/management/commands/fix_track_files.py +++ b/api/funkwhale_api/music/management/commands/fix_track_files.py @@ -2,6 +2,7 @@ import cacheops import os from django.db import transaction +from django.db.models import Q from django.conf import settings from django.core.management.base import BaseCommand, CommandError @@ -24,6 +25,8 @@ class Command(BaseCommand): if options['dry_run']: self.stdout.write('Dry-run on, will not commit anything') self.fix_mimetypes(**options) + self.fix_file_data(**options) + self.fix_file_size(**options) cacheops.invalidate_model(models.TrackFile) @transaction.atomic @@ -43,3 +46,60 @@ class Command(BaseCommand): if not dry_run: self.stdout.write('[mimetypes] commiting...') qs.update(mimetype=mimetype) + + def fix_file_data(self, dry_run, **kwargs): + self.stdout.write('Fixing missing bitrate or length...') + matching = models.TrackFile.objects.filter( + Q(bitrate__isnull=True) | Q(duration__isnull=True)) + total = matching.count() + self.stdout.write( + '[bitrate/length] {} entries found with missing values'.format( + total)) + if dry_run: + return + for i, tf in enumerate(matching.only('audio_file')): + self.stdout.write( + '[bitrate/length] {}/{} fixing file #{}'.format( + i+1, total, tf.pk + )) + + try: + audio_file = tf.get_audio_file() + if audio_file: + with audio_file as f: + data = utils.get_audio_file_data(audio_file) + tf.bitrate = data['bitrate'] + tf.duration = data['length'] + tf.save(update_fields=['duration', 'bitrate']) + else: + self.stderr.write('[bitrate/length] no file found') + except Exception as e: + self.stderr.write( + '[bitrate/length] error with file #{}: {}'.format( + tf.pk, str(e) + ) + ) + + def fix_file_size(self, dry_run, **kwargs): + self.stdout.write('Fixing missing size...') + matching = models.TrackFile.objects.filter(size__isnull=True) + total = matching.count() + self.stdout.write( + '[size] {} entries found with missing values'.format(total)) + if dry_run: + return + for i, tf in enumerate(matching.only('size')): + self.stdout.write( + '[size] {}/{} fixing file #{}'.format( + i+1, total, tf.pk + )) + + try: + tf.size = tf.get_file_size() + tf.save(update_fields=['size']) + except Exception as e: + self.stderr.write( + '[size] error with file #{}: {}'.format( + tf.pk, str(e) + ) + ) diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index a200697834d1f68aff4291c5fcf9857ad401a1cd..49425671199d5888ded30df31f9465ee9f239355 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -28,6 +28,13 @@ def get_id3_tag(f, k): raise TagNotFound(k) +def get_flac_tag(f, k): + try: + return f.get(k)[0] + except (KeyError, IndexError): + raise TagNotFound(k) + + def get_mp3_recording_id(f, k): try: return [ @@ -121,7 +128,38 @@ CONF = { 'getter': get_mp3_recording_id, }, } - } + }, + 'FLAC': { + 'getter': get_flac_tag, + 'fields': { + 'track_number': { + 'field': 'tracknumber', + 'to_application': convert_track_number + }, + 'title': { + 'field': 'title' + }, + 'artist': { + 'field': 'artist' + }, + 'album': { + 'field': 'album' + }, + 'date': { + 'field': 'date', + 'to_application': lambda v: arrow.get(str(v)).date() + }, + 'musicbrainz_albumid': { + 'field': 'musicbrainz_albumid' + }, + 'musicbrainz_artistid': { + 'field': 'musicbrainz_artistid' + }, + 'musicbrainz_recordingid': { + 'field': 'musicbrainz_trackid' + }, + } + }, } diff --git a/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py new file mode 100644 index 0000000000000000000000000000000000000000..835e115a6571c010148c939f11f15023a1d42475 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0027_auto_20180515_1808.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.3 on 2018-05-15 18:08 + +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0026_trackfile_accessed_date'), + ] + + operations = [ + migrations.AddField( + model_name='trackfile', + name='bitrate', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='trackfile', + name='size', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='track', + name='tags', + field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 5ee5d851dc122f2aa632952eed2b218d76b0dfd7..1259cc3c12406a7848649d829dfed7e8999f4539 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -361,7 +361,7 @@ class Track(APIModelMixin): import_tags ] objects = TrackQuerySet.as_manager() - tags = TaggableManager() + tags = TaggableManager(blank=True) class Meta: ordering = ['album', 'position'] @@ -429,6 +429,8 @@ class TrackFile(models.Model): modification_date = models.DateTimeField(auto_now=True) accessed_date = models.DateTimeField(null=True, blank=True) duration = models.IntegerField(null=True, blank=True) + size = models.IntegerField(null=True, blank=True) + bitrate = models.IntegerField(null=True, blank=True) acoustid_track_id = models.UUIDField(null=True, blank=True) mimetype = models.CharField(null=True, blank=True, max_length=200) @@ -467,7 +469,7 @@ class TrackFile(models.Model): @property def filename(self): - return '{}{}'.format( + return '{}.{}'.format( self.track.full_name, self.extension) @@ -477,6 +479,41 @@ class TrackFile(models.Model): return return os.path.splitext(self.audio_file.name)[-1].replace('.', '', 1) + def get_file_size(self): + if self.audio_file: + return self.audio_file.size + + if self.source.startswith('file://'): + return os.path.getsize(self.source.replace('file://', '', 1)) + + if self.library_track and self.library_track.audio_file: + return self.library_track.audio_file.size + + def get_audio_file(self): + if self.audio_file: + return self.audio_file.open() + if self.source.startswith('file://'): + return open(self.source.replace('file://', '', 1), 'rb') + if self.library_track and self.library_track.audio_file: + return self.library_track.audio_file.open() + + def set_audio_data(self): + audio_file = self.get_audio_file() + if audio_file: + with audio_file as f: + audio_data = utils.get_audio_file_data(f) + if not audio_data: + return + self.duration = int(audio_data['length']) + self.bitrate = audio_data['bitrate'] + self.size = self.get_file_size() + else: + lt = self.library_track + if lt: + self.duration = lt.get_metadata('length') + self.size = lt.get_metadata('size') + self.bitrate = lt.get_metadata('bitrate') + def save(self, **kwargs): if not self.mimetype and self.audio_file: self.mimetype = utils.guess_mimetype(self.audio_file) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 9dfc9147872871b0f0ba500c213f812b79bad8d7..d9d48496e487395be4ff0516b64f43b1074f37c1 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -27,6 +27,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer): class ArtistSerializer(serializers.ModelSerializer): tags = TagSerializer(many=True, read_only=True) + class Meta: model = models.Artist fields = ('id', 'mbid', 'name', 'tags', 'creation_date') @@ -40,11 +41,21 @@ class TrackFileSerializer(serializers.ModelSerializer): fields = ( 'id', 'path', - 'duration', 'source', 'filename', 'mimetype', - 'track') + 'track', + 'duration', + 'mimetype', + 'bitrate', + 'size', + ) + read_only_fields = [ + 'duration', + 'mimetype', + 'bitrate', + 'size', + ] def get_path(self, o): url = o.path diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index bad0006aa98520e2afac00aad8d5466bde2b8934..34345e47b49e9f44f41bb341ebdb059ff5185464 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -134,6 +134,7 @@ def _do_import(import_job, replace=False, use_acoustid=True): # in place import, we set mimetype from extension path, ext = os.path.splitext(import_job.source) track_file.mimetype = music_utils.get_type_from_ext(ext) + track_file.set_audio_data() track_file.save() import_job.status = 'finished' import_job.track_file = track_file diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 49a63930349a081178bc36c87e73a702e4d9faac..f11e4507a7a0bc7d1648e5dfda18f3deb4542286 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -1,5 +1,6 @@ import magic import mimetypes +import mutagen import re from django.db.models import Q @@ -66,6 +67,7 @@ def compute_status(jobs): AUDIO_EXTENSIONS_AND_MIMETYPE = [ ('ogg', 'audio/ogg'), ('mp3', 'audio/mpeg'), + ('flac', 'audio/x-flac'), ] EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE} @@ -81,3 +83,14 @@ def get_type_from_ext(extension): # we remove leading dot extension = extension[1:] return EXTENSION_TO_MIMETYPE.get(extension) + + +def get_audio_file_data(f): + data = mutagen.File(f) + if not data: + return + d = {} + d['bitrate'] = data.info.bitrate + d['length'] = data.info.length + + return d diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 98274e741293f6ef9b4b9cf9ce5f59e32eb8815f..e71d3555e67a21012ee34d79e2fd56b28354fcf8 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -25,8 +25,8 @@ from rest_framework import permissions from musicbrainzngs import ResponseError from funkwhale_api.common import utils as funkwhale_utils -from funkwhale_api.common.permissions import ( - ConditionalAuthentication, HasModelPermission) +from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.users.permissions import HasUserPermission from taggit.models import Tag from funkwhale_api.federation import actors from funkwhale_api.federation.authentication import SignatureAuthentication @@ -107,25 +107,22 @@ class ImportBatchViewSet( .annotate(job_count=Count('jobs')) ) serializer_class = serializers.ImportBatchSerializer - permission_classes = (permissions.DjangoModelPermissions, ) + permission_classes = (HasUserPermission,) + required_permissions = ['library'] filter_class = filters.ImportBatchFilter def perform_create(self, serializer): serializer.save(submitted_by=self.request.user) -class ImportJobPermission(HasModelPermission): - # not a typo, perms on import job is proxied to import batch - model = models.ImportBatch - - class ImportJobViewSet( mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): queryset = (models.ImportJob.objects.all().select_related()) serializer_class = serializers.ImportJobSerializer - permission_classes = (ImportJobPermission, ) + permission_classes = (HasUserPermission,) + required_permissions = ['library'] filter_class = filters.ImportJobFilter @list_route(methods=['get']) @@ -230,7 +227,7 @@ def get_file_path(audio_file): 'MUSIC_DIRECTORY_PATH to serve in-place imported files' ) path = '/music' + audio_file.replace(prefix, '', 1) - return settings.PROTECT_FILES_PATH + path + return (settings.PROTECT_FILES_PATH + path).encode('utf-8') if t == 'apache2': try: path = audio_file.path @@ -241,7 +238,7 @@ def get_file_path(audio_file): 'You need to specify MUSIC_DIRECTORY_SERVE_PATH and ' 'MUSIC_DIRECTORY_PATH to serve in-place imported files' ) - path = audio_file.replace(prefix, serve_path, 1) + path = audio_file.replace(prefix, serve_path, 1).encode('utf-8') return path @@ -268,6 +265,10 @@ def handle_serve(track_file): qs = LibraryTrack.objects.select_for_update() library_track = qs.get(pk=library_track.pk) library_track.download_audio() + track_file.library_track = library_track + track_file.set_audio_data() + track_file.save(update_fields=['bitrate', 'duration', 'size']) + audio_file = library_track.audio_file file_path = get_file_path(audio_file) mt = library_track.audio_mimetype @@ -275,7 +276,10 @@ def handle_serve(track_file): file_path = get_file_path(audio_file) elif f.source and f.source.startswith('file://'): file_path = get_file_path(f.source.replace('file://', '', 1)) - response = Response() + if mt: + response = Response(content_type=mt) + else: + response = Response() filename = f.filename mapping = { 'nginx': 'X-Accel-Redirect', @@ -293,7 +297,11 @@ def handle_serve(track_file): class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): - queryset = (models.TrackFile.objects.all().order_by('-id')) + queryset = ( + models.TrackFile.objects.all() + .select_related('track__artist', 'track__album') + .order_by('-id') + ) serializer_class = serializers.TrackFileSerializer authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [ SignatureAuthentication @@ -431,7 +439,8 @@ class Search(views.APIView): class SubmitViewSet(viewsets.ViewSet): queryset = models.ImportBatch.objects.none() - permission_classes = (permissions.DjangoModelPermissions, ) + permission_classes = (HasUserPermission,) + required_permissions = ['library'] @list_route(methods=['post']) @transaction.non_atomic_requests diff --git a/api/funkwhale_api/playlists/dynamic_preferences_registry.py b/api/funkwhale_api/playlists/dynamic_preferences_registry.py index 21140fa1495adb734aa64e41be4bae218ba74304..b717177a2368ccfb267f651bd76def8c6e9fc443 100644 --- a/api/funkwhale_api/playlists/dynamic_preferences_registry.py +++ b/api/funkwhale_api/playlists/dynamic_preferences_registry.py @@ -13,3 +13,6 @@ class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference): name = 'max_tracks' verbose_name = 'Max tracks per playlist' setting = 'PLAYLISTS_MAX_TRACKS' + field_kwargs = { + 'required': False, + } diff --git a/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py b/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py index da785df4065aff76cf8d1a4e9d19967b0b7bfa9c..33c9643b00b4ec96665a7e8cbcfae13098d13a04 100644 --- a/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py +++ b/api/funkwhale_api/providers/acoustid/dynamic_preferences_registry.py @@ -1,3 +1,5 @@ +from django import forms + from dynamic_preferences.types import StringPreference, Section from dynamic_preferences.registries import global_preferences_registry @@ -11,3 +13,7 @@ class APIKey(StringPreference): default = '' verbose_name = 'Acoustid API key' help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.' + widget = forms.PasswordInput + field_kwargs = { + 'required': False, + } diff --git a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py index fc7f7d793e5a535f344d63678f418b0736ae2862..ac5fc4bde29a313429b7644b51846fa3e33b0465 100644 --- a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py +++ b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py @@ -1,3 +1,5 @@ +from django import forms + from dynamic_preferences.types import StringPreference, Section from dynamic_preferences.registries import global_preferences_registry @@ -11,3 +13,7 @@ class APIKey(StringPreference): default = 'CHANGEME' verbose_name = 'YouTube API key' help_text = 'The API key used to query YouTube. Get one at https://console.developers.google.com/.' + widget = forms.PasswordInput + field_kwargs = { + 'required': False, + } diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py index 74cf13d887d9186a1bd50b4569fe59970daff762..3a56645012f07910a9c0e175b3b3e3d6f6bec8f2 100644 --- a/api/funkwhale_api/subsonic/renderers.py +++ b/api/funkwhale_api/subsonic/renderers.py @@ -15,6 +15,9 @@ class SubsonicJSONRenderer(renderers.JSONRenderer): } } final['subsonic-response'].update(data) + if 'error' in final: + # an error was returned + final['subsonic-response']['status'] = 'failed' return super().render(final, accepted_media_type, renderer_context) @@ -31,6 +34,9 @@ class SubsonicXMLRenderer(renderers.JSONRenderer): 'version': '1.16.0', } final.update(data) + if 'error' in final: + # an error was returned + final['status'] = 'failed' tree = dict_to_xml_tree('subsonic-response', final) return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(tree, encoding='utf-8') diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py index 5bc452886d7486bdc0ad7c1f5571ea69fc405895..6709930f56756abae175f1158bae0f9d94ec67b7 100644 --- a/api/funkwhale_api/subsonic/serializers.py +++ b/api/funkwhale_api/subsonic/serializers.py @@ -81,6 +81,10 @@ def get_track_data(album, track, tf): 'artistId': album.artist.pk, 'type': 'music', } + if tf.bitrate: + data['bitrate'] = int(tf.bitrate/1000) + if tf.size: + data['size'] = tf.size if album.release_date: data['year'] = album.release_date.year return data @@ -211,5 +215,9 @@ def get_music_directory_data(artist): 'parent': artist.id, 'type': 'music', } + if tf.bitrate: + td['bitrate'] = int(tf.bitrate/1000) + if tf.size: + td['size'] = tf.size data['child'].append(td) return data diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index 475e61aa73119ff8df9a71fa2a27cf3188dc0cff..2692a3dda804ad22c07ee340012ad037afb5e42b 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -31,15 +31,19 @@ def find_object(queryset, model_field='pk', field='id', cast=int): raw_value = data[field] except KeyError: return response.Response({ - 'code': 10, - 'message': "required parameter '{}' not present".format(field) + 'error': { + 'code': 10, + 'message': "required parameter '{}' not present".format(field) + } }) try: value = cast(raw_value) except (TypeError, ValidationError): return response.Response({ - 'code': 0, - 'message': 'For input string "{}"'.format(raw_value) + 'error': { + 'code': 0, + 'message': 'For input string "{}"'.format(raw_value) + } }) qs = queryset if hasattr(qs, '__call__'): @@ -48,9 +52,11 @@ def find_object(queryset, model_field='pk', field='id', cast=int): obj = qs.get(**{model_field: value}) except qs.model.DoesNotExist: return response.Response({ - 'code': 70, - 'message': '{} not found'.format( - qs.model.__class__.__name__) + 'error': { + 'code': 70, + 'message': '{} not found'.format( + qs.model.__class__.__name__) + } }) kwargs['obj'] = obj return func(self, request, *args, **kwargs) @@ -83,15 +89,14 @@ class SubsonicViewSet(viewsets.GenericViewSet): payload = { 'status': 'failed' } - try: + if exc.__class__ in mapping: code, message = mapping[exc.__class__] - except KeyError: - return super().handle_exception(exc) else: - payload['error'] = { - 'code': code, - 'message': message - } + return super().handle_exception(exc) + payload['error'] = { + 'code': code, + 'message': message + } return response.Response(payload, status=200) @@ -450,8 +455,10 @@ class SubsonicViewSet(viewsets.GenericViewSet): name = data.get('name', '') if not name: return response.Response({ - 'code': 10, - 'message': 'Playlist ID or name must be specified.' + 'error': { + 'code': 10, + 'message': 'Playlist ID or name must be specified.' + } }, data) playlist = request.user.playlists.create( diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index 89b67d3df96de05b9ffd8fff9ce027f67f101318..7e9062a1308a298fb83453fa0cb1c53c5740dd51 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -5,6 +5,7 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as AuthUserAdmin from django.contrib.auth.forms import UserChangeForm, UserCreationForm +from django.utils.translation import ugettext_lazy as _ from .models import User @@ -41,8 +42,33 @@ class UserAdmin(AuthUserAdmin): 'email', 'date_joined', 'last_login', - 'privacy_level', + 'is_staff', + 'is_superuser', ] list_filter = [ + 'is_superuser', + 'is_staff', 'privacy_level', + 'permission_settings', + 'permission_library', + 'permission_federation', ] + + fieldsets = ( + (None, {'fields': ('username', 'password', 'privacy_level')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), + (_('Permissions'), { + 'fields': ( + 'is_active', + 'is_staff', + 'is_superuser', + 'permission_library', + 'permission_settings', + 'permission_federation')}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + (_('Useless fields'), { + 'fields': ( + 'user_permissions', + 'groups', + )}) + ) diff --git a/api/funkwhale_api/users/dynamic_preferences_registry.py b/api/funkwhale_api/users/dynamic_preferences_registry.py index 16d79da143cb3139f9a92137044b6092288e1b40..4f736053088df4fad3ccd804744c8dec826d388f 100644 --- a/api/funkwhale_api/users/dynamic_preferences_registry.py +++ b/api/funkwhale_api/users/dynamic_preferences_registry.py @@ -10,6 +10,7 @@ class RegistrationEnabled(types.BooleanPreference): section = users name = 'registration_enabled' default = False - verbose_name = ( - 'Can visitors open a new account on this instance?' + verbose_name = 'Open registrations to new users' + help_text = ( + 'When enabled, new users will be able to register on this instance.' ) diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py index 12307f7fd109407f025b05dd364146204a59ae27..cd28f44073ded3ad425ee3c1f84a7c4b6dcabec6 100644 --- a/api/funkwhale_api/users/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -1,15 +1,41 @@ import factory -from funkwhale_api.factories import registry +from funkwhale_api.factories import registry, ManyToManyFromList from django.contrib.auth.models import Permission +@registry.register +class GroupFactory(factory.django.DjangoModelFactory): + name = factory.Sequence(lambda n: 'group-{0}'.format(n)) + + class Meta: + model = 'auth.Group' + + @factory.post_generation + def perms(self, create, extracted, **kwargs): + if not create: + # Simple build, do nothing. + return + + if extracted: + perms = [ + Permission.objects.get( + content_type__app_label=p.split('.')[0], + codename=p.split('.')[1], + ) + for p in extracted + ] + # A list of permissions were passed in, use them + self.permissions.add(*perms) + + @registry.register class UserFactory(factory.django.DjangoModelFactory): username = factory.Sequence(lambda n: 'user-{0}'.format(n)) email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n)) password = factory.PostGenerationMethodCall('set_password', 'test') subsonic_api_token = None + groups = ManyToManyFromList('groups') class Meta: model = 'users.User' diff --git a/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py new file mode 100644 index 0000000000000000000000000000000000000000..7c9ab0fadc99016e8a62f609ace117ea0b941965 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py @@ -0,0 +1,28 @@ +# Generated by Django 2.0.4 on 2018-05-17 23:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_user_subsonic_api_token'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='permission_federation', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='permission_library', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='permission_settings', + field=models.BooleanField(default=False), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 773d60f38ebec50dd46cda63b05b37ac4659573c..c16cd62b319c01d4a9f1802c531d279f0ef802ed 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import +import binascii +import os import uuid -import secrets from django.conf import settings -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractUser, Permission from django.urls import reverse from django.db import models from django.utils.encoding import python_2_unicode_compatible @@ -14,6 +15,17 @@ from django.utils.translation import ugettext_lazy as _ from funkwhale_api.common import fields +def get_token(): + return binascii.b2a_hex(os.urandom(15)).decode('utf-8') + + +PERMISSIONS = [ + 'federation', + 'library', + 'settings', +] + + @python_2_unicode_compatible class User(AbstractUser): @@ -23,20 +35,6 @@ class User(AbstractUser): # updated on logout or password change, to invalidate JWT secret_key = models.UUIDField(default=uuid.uuid4, null=True) - # permissions that are used for API access and that worth serializing - relevant_permissions = { - # internal_codename : {external_codename} - 'music.add_importbatch': { - 'external_codename': 'import.launch', - }, - 'dynamic_preferences.change_globalpreferencemodel': { - 'external_codename': 'settings.change', - }, - 'federation.change_library': { - 'external_codename': 'federation.manage', - }, - } - privacy_level = fields.get_privacy_field() # Unfortunately, Subsonic API assumes a MD5/password authentication @@ -47,9 +45,33 @@ class User(AbstractUser): subsonic_api_token = models.CharField( blank=True, null=True, max_length=255) + # permissions + permission_federation = models.BooleanField( + 'Manage library federation', + help_text='Follow other instances, accept/deny library follow requests...', + default=False) + permission_library = models.BooleanField( + 'Manage library', + help_text='Import new content, manage existing content', + default=False) + permission_settings = models.BooleanField( + 'Manage instance-level settings', + default=False) + def __str__(self): return self.username + def get_permissions(self): + perms = {} + for p in PERMISSIONS: + v = self.is_superuser or getattr(self, 'permission_{}'.format(p)) + perms[p] = v + return perms + + def has_permissions(self, *perms): + permissions = self.get_permissions() + return all([permissions[p] for p in perms]) + def get_absolute_url(self): return reverse('users:detail', kwargs={'username': self.username}) @@ -58,7 +80,7 @@ class User(AbstractUser): return self.secret_key def update_subsonic_api_token(self): - self.subsonic_api_token = secrets.token_hex(32) + self.subsonic_api_token = get_token() return self.subsonic_api_token def set_password(self, raw_password): diff --git a/api/funkwhale_api/users/permissions.py b/api/funkwhale_api/users/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..2ff49ff3fa6661aecd0616fdb87b5b43f89a95c2 --- /dev/null +++ b/api/funkwhale_api/users/permissions.py @@ -0,0 +1,19 @@ +from rest_framework.permissions import BasePermission + + +class HasUserPermission(BasePermission): + """ + Ensure the request user has the proper permissions. + + Usage: + + class MyView(APIView): + permission_classes = [HasUserPermission] + required_permissions = ['federation'] + """ + def has_permission(self, request, view): + if not hasattr(request, 'user') or not request.user: + return False + if request.user.is_anonymous: + return False + return request.user.has_permissions(*view.required_permissions) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index eadce6154fa12c385f0655d3667b1634442716ec..3a095e78aa727b52ed35153cfffc8419b6142172 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -55,16 +55,11 @@ class UserReadSerializer(serializers.ModelSerializer): 'is_superuser', 'permissions', 'date_joined', - 'privacy_level' + 'privacy_level', ] def get_permissions(self, o): - perms = {} - for internal_codename, conf in o.relevant_permissions.items(): - perms[conf['external_codename']] = { - 'status': o.has_perm(internal_codename) - } - return perms + return o.get_permissions() class PasswordResetSerializer(PRS): diff --git a/api/tests/common/test_scripts.py b/api/tests/common/test_scripts.py new file mode 100644 index 0000000000000000000000000000000000000000..ce478ba048043b6c1da61d0febf72fd91e8c2444 --- /dev/null +++ b/api/tests/common/test_scripts.py @@ -0,0 +1,56 @@ +import pytest + +from funkwhale_api.common.management.commands import script +from funkwhale_api.common import scripts + + +@pytest.fixture +def command(): + return script.Command() + + +@pytest.mark.parametrize('script_name', [ + 'django_permissions_to_user_permissions', + 'test', +]) +def test_script_command_list(command, script_name, mocker): + mocked = mocker.patch( + 'funkwhale_api.common.scripts.{}.main'.format(script_name)) + + command.handle(script_name=script_name, interactive=False) + + mocked.assert_called_once_with( + command, script_name=script_name, interactive=False) + + +def test_django_permissions_to_user_permissions(factories, command): + group = factories['auth.Group']( + perms=[ + 'federation.change_library' + ] + ) + user1 = factories['users.User']( + perms=[ + 'dynamic_preferences.change_globalpreferencemodel', + 'music.add_importbatch', + ] + ) + user2 = factories['users.User']( + perms=[ + 'music.add_importbatch', + ], + groups=[group] + ) + + scripts.django_permissions_to_user_permissions.main(command) + + user1.refresh_from_db() + user2.refresh_from_db() + + assert user1.permission_settings is True + assert user1.permission_library is True + assert user1.permission_federation is False + + assert user2.permission_settings is False + assert user2.permission_library is True + assert user2.permission_federation is True diff --git a/api/tests/conftest.py b/api/tests/conftest.py index dda537801f3cf66efb7eec4e823816e5dcec8207..b7a7d071ab6e8e548b7026b84ff815c0f2e6e43a 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -14,6 +14,7 @@ from rest_framework.test import APIClient from rest_framework.test import APIRequestFactory from funkwhale_api.activity import record +from funkwhale_api.users.permissions import HasUserPermission from funkwhale_api.taskapp import celery @@ -224,3 +225,11 @@ def authenticated_actor(factories, mocker): 'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor', return_value=actor) yield actor + + +@pytest.fixture +def assert_user_permission(): + def inner(view, permissions): + assert HasUserPermission in view.permission_classes + assert set(view.required_permissions) == set(permissions) + return inner diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 85208fa490b53dd52b998b24f6f743f9d465d785..f298c61f5fbd36f9516e607d00e60649c4b9b281 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -533,7 +533,12 @@ def test_activity_pub_audio_serializer_to_library_track_no_duplicate( def test_activity_pub_audio_serializer_to_ap(factories): - tf = factories['music.TrackFile'](mimetype='audio/mp3') + tf = factories['music.TrackFile']( + mimetype='audio/mp3', + bitrate=42, + duration=43, + size=44, + ) library = actors.SYSTEM_ACTORS['library'].get_actor_instance() expected = { '@context': serializers.AP_CONTEXT, @@ -555,6 +560,9 @@ def test_activity_pub_audio_serializer_to_ap(factories): 'musicbrainz_id': tf.track.mbid, 'title': tf.track.title, }, + 'size': tf.size, + 'length': tf.duration, + 'bitrate': tf.bitrate, }, 'url': { 'href': utils.full_url(tf.path), @@ -599,6 +607,9 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories): 'title': tf.track.title, 'musicbrainz_id': None, }, + 'size': None, + 'length': None, + 'bitrate': None, }, 'url': { 'href': utils.full_url(tf.path), diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index cc81f0657754960d1389bfe36bb6c02787f4c5a3..10237ed9fd76d156656238edbce11495dc08934b 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -9,9 +9,18 @@ from funkwhale_api.federation import activity from funkwhale_api.federation import models from funkwhale_api.federation import serializers from funkwhale_api.federation import utils +from funkwhale_api.federation import views from funkwhale_api.federation import webfinger +@pytest.mark.parametrize('view,permissions', [ + (views.LibraryViewSet, ['federation']), + (views.LibraryTrackViewSet, ['federation']), +]) +def test_permissions(assert_user_permission, view, permissions): + assert_user_permission(view, permissions) + + @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys()) def test_instance_actors(system_actor, db, api_client): actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() @@ -62,7 +71,10 @@ def test_wellknown_webfinger_system( actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() url = reverse('federation:well-known-webfinger') response = api_client.get( - url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)}) + url, + data={'resource': 'acct:{}'.format(actor.webfinger_subject)}, + HTTP_ACCEPT='application/jrd+json', + ) serializer = serializers.ActorWebfingerSerializer(actor) assert response.status_code == 200 @@ -83,7 +95,7 @@ def test_wellknown_nodeinfo(db, preferences, api_client, settings): ] } url = reverse('federation:well-known-nodeinfo') - response = api_client.get(url) + response = api_client.get(url, HTTP_ACCEPT='application/jrd+json') assert response.status_code == 200 assert response['Content-Type'] == 'application/jrd+json' assert response.data == expected diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py index 4ca1c43a5835f94a6db20018a76e456c5e1b7e28..87b8882880752d14a5572ca7a324d399688974e7 100644 --- a/api/tests/instance/test_nodeinfo.py +++ b/api/tests/instance/test_nodeinfo.py @@ -36,6 +36,7 @@ def test_nodeinfo_dump(preferences, mocker): } }, 'metadata': { + 'private': preferences['instance__nodeinfo_private'], 'shortDescription': preferences['instance__short_description'], 'longDescription': preferences['instance__long_description'], 'nodeName': preferences['instance__name'], @@ -92,6 +93,7 @@ def test_nodeinfo_dump_stats_disabled(preferences, mocker): } }, 'metadata': { + 'private': preferences['instance__nodeinfo_private'], 'shortDescription': preferences['instance__short_description'], 'longDescription': preferences['instance__long_description'], 'nodeName': preferences['instance__name'], diff --git a/api/tests/instance/test_views.py b/api/tests/instance/test_views.py index 468c0ddae9de440b3edce7fd65fdc57c6ead8fff..daf54db51cb32181c380fca8719bc951c97404c6 100644 --- a/api/tests/instance/test_views.py +++ b/api/tests/instance/test_views.py @@ -1,5 +1,16 @@ +import pytest + from django.urls import reverse +from funkwhale_api.instance import views + + +@pytest.mark.parametrize('view,permissions', [ + (views.AdminSettings, ['settings']), +]) +def test_permissions(assert_user_permission, view, permissions): + assert_user_permission(view, permissions) + def test_nodeinfo_endpoint(db, api_client, mocker): payload = { @@ -21,3 +32,32 @@ def test_nodeinfo_endpoint_disabled(db, api_client, preferences): response = api_client.get(url) assert response.status_code == 404 + + +def test_settings_only_list_public_settings(db, api_client, preferences): + url = reverse('api:v1:instance:settings') + response = api_client.get(url) + + for conf in response.data: + p = preferences.model.objects.get( + section=conf['section'], name=conf['name']) + assert p.preference.show_in_api is True + + +def test_admin_settings_restrict_access(db, logged_in_api_client, preferences): + url = reverse('api:v1:instance:admin-settings-list') + response = logged_in_api_client.get(url) + + assert response.status_code == 403 + + +def test_admin_settings_correct_permission( + db, logged_in_api_client, preferences): + user = logged_in_api_client.user + user.permission_settings = True + user.save() + url = reverse('api:v1:instance:admin-settings-list') + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert len(response.data) == len(preferences.all()) diff --git a/api/tests/music/sample.flac b/api/tests/music/sample.flac new file mode 100644 index 0000000000000000000000000000000000000000..6eff1c06e43f6f536c2d810d0d059cc6af319f5e Binary files /dev/null and b/api/tests/music/sample.flac differ diff --git a/api/tests/music/test_commands.py b/api/tests/music/test_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..ff3343aa53fd13be34c99316c2da38c188571e93 --- /dev/null +++ b/api/tests/music/test_commands.py @@ -0,0 +1,45 @@ +from funkwhale_api.music.management.commands import fix_track_files + + +def test_fix_track_files_bitrate_length(factories, mocker): + tf1 = factories['music.TrackFile'](bitrate=1, duration=2) + tf2 = factories['music.TrackFile'](bitrate=None, duration=None) + c = fix_track_files.Command() + + mocker.patch( + 'funkwhale_api.music.utils.get_audio_file_data', + return_value={'bitrate': 42, 'length': 43}) + + c.fix_file_data(dry_run=False) + + tf1.refresh_from_db() + tf2.refresh_from_db() + + # not updated + assert tf1.bitrate == 1 + assert tf1.duration == 2 + + # updated + assert tf2.bitrate == 42 + assert tf2.duration == 43 + + +def test_fix_track_files_size(factories, mocker): + tf1 = factories['music.TrackFile'](size=1) + tf2 = factories['music.TrackFile'](size=None) + c = fix_track_files.Command() + + mocker.patch( + 'funkwhale_api.music.models.TrackFile.get_file_size', + return_value=2) + + c.fix_file_size(dry_run=False) + + tf1.refresh_from_db() + tf2.refresh_from_db() + + # not updated + assert tf1.size == 1 + + # updated + assert tf2.size == 2 diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py index c7b40fb16e9ebc50e33060b88c0c82947e808451..8453dca8407ad551f70930a00d5e16494560ab38 100644 --- a/api/tests/music/test_import.py +++ b/api/tests/music/test_import.py @@ -1,4 +1,5 @@ import json +import os import pytest from django.urls import reverse @@ -7,6 +8,8 @@ from funkwhale_api.federation import actors from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.music import tasks +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + def test_create_import_can_bind_to_request( artists, albums, mocker, factories, superuser_api_client): @@ -40,11 +43,20 @@ def test_create_import_can_bind_to_request( assert batch.import_request == request -def test_import_job_from_federation_no_musicbrainz(factories): +def test_import_job_from_federation_no_musicbrainz(factories, mocker): + mocker.patch( + 'funkwhale_api.music.utils.get_audio_file_data', + return_value={'bitrate': 24, 'length': 666}) + mocker.patch( + 'funkwhale_api.music.models.TrackFile.get_file_size', + return_value=42) lt = factories['federation.LibraryTrack']( artist_name='Hello', album_title='World', title='Ping', + metadata__length=42, + metadata__bitrate=43, + metadata__size=44, ) job = factories['music.ImportJob']( federation=True, @@ -56,6 +68,9 @@ def test_import_job_from_federation_no_musicbrainz(factories): tf = job.track_file assert tf.mimetype == lt.audio_mimetype + assert tf.duration == 42 + assert tf.bitrate == 43 + assert tf.size == 44 assert tf.library_track == job.library_track assert tf.track.title == 'Ping' assert tf.track.artist.name == 'Hello' @@ -234,13 +249,13 @@ def test_import_batch_notifies_followers( def test__do_import_in_place_mbid(factories, tmpfile): - path = '/test.ogg' + path = os.path.join(DATA_DIR, 'test.ogg') job = factories['music.ImportJob']( - in_place=True, source='file:///test.ogg') + in_place=True, source='file://{}'.format(path)) track = factories['music.Track'](mbid=job.mbid) tf = tasks._do_import(job, use_acoustid=False) assert bool(tf.audio_file) is False - assert tf.source == 'file:///test.ogg' + assert tf.source == 'file://{}'.format(path) assert tf.mimetype == 'audio/ogg' diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index 342bc99b8115cf0646da9c9e61c10fe645738ec3..3f1ea9177d8e69f34294cabd9b42156e85644e86 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -40,3 +40,20 @@ def test_can_get_metadata_from_id3_mp3_file(field, value): data = metadata.Metadata(path) assert data.get(field) == value + + +@pytest.mark.parametrize('field,value', [ + ('title', '999,999'), + ('artist', 'Nine Inch Nails'), + ('album', 'The Slip'), + ('date', datetime.date(2008, 5, 5)), + ('track_number', 1), + ('musicbrainz_albumid', uuid.UUID('12b57d46-a192-499e-a91f-7da66790a1c1')), + ('musicbrainz_recordingid', uuid.UUID('30f3f33e-8d0c-4e69-8539-cbd701d18f28')), + ('musicbrainz_artistid', uuid.UUID('b7ffd2af-418f-4be2-bdd1-22f8b48613da')), +]) +def test_can_get_metadata_from_flac_file(field, value): + path = os.path.join(DATA_DIR, 'sample.flac') + data = metadata.Metadata(path) + + assert data.get(field) == value diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 9f52ba8874e50c10ea0216cacc8363ab767d6396..e926d07fa2be7c367b50e53acbc72666eca8b06b 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -77,3 +77,36 @@ def test_audio_track_mime_type(extention, mimetype, factories): tf = factories['music.TrackFile'](audio_file__from_path=path) assert tf.mimetype == mimetype + + +def test_track_file_file_name(factories): + name = 'test.mp3' + path = os.path.join(DATA_DIR, name) + tf = factories['music.TrackFile'](audio_file__from_path=path) + + assert tf.filename == tf.track.full_name + '.mp3' + + +def test_track_get_file_size(factories): + name = 'test.mp3' + path = os.path.join(DATA_DIR, name) + tf = factories['music.TrackFile'](audio_file__from_path=path) + + assert tf.get_file_size() == 297745 + + +def test_track_get_file_size_federation(factories): + tf = factories['music.TrackFile']( + federation=True, + library_track__with_audio_file=True) + + assert tf.get_file_size() == tf.library_track.audio_file.size + + +def test_track_get_file_size_in_place(factories): + name = 'test.mp3' + path = os.path.join(DATA_DIR, name) + tf = factories['music.TrackFile']( + in_place=True, source='file://{}'.format(path)) + + assert tf.get_file_size() == 297745 diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index ddbc4ba9a2c7407bd067dc2799f499654cbb004c..c5839432bf128a3a03387bb2894d8ad56615594b 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -62,6 +62,9 @@ def test_import_job_can_run_with_file_and_acoustid( 'score': 0.860825}], 'status': 'ok' } + mocker.patch( + 'funkwhale_api.music.utils.get_audio_file_data', + return_value={'bitrate': 42, 'length': 43}) mocker.patch( 'funkwhale_api.musicbrainz.api.artists.get', return_value=artists['get']['adhesive_wombat']) @@ -82,7 +85,9 @@ def test_import_job_can_run_with_file_and_acoustid( with open(path, 'rb') as f: assert track_file.audio_file.read() == f.read() - assert track_file.duration == 268 + assert track_file.bitrate == 42 + assert track_file.duration == 43 + assert track_file.size == os.path.getsize(path) # audio file is deleted from import job once persisted to audio file assert not job.audio_file assert job.status == 'finished' diff --git a/api/tests/music/test_utils.py b/api/tests/music/test_utils.py index 0a4f4b99424c34557fb846866b6e1b2958244a88..12b381a997c59ea85ebf1239024b1d3f288e2cdf 100644 --- a/api/tests/music/test_utils.py +++ b/api/tests/music/test_utils.py @@ -1,5 +1,10 @@ +import os +import pytest + from funkwhale_api.music import utils +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + def test_guess_mimetype_try_using_extension(factories, mocker): mocker.patch( @@ -17,3 +22,16 @@ def test_guess_mimetype_try_using_extension_if_fail(factories, mocker): audio_file__filename='test.mp3') assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg' + + +@pytest.mark.parametrize('name, expected', [ + ('sample.flac', {'bitrate': 1608000, 'length': 0.001}), + ('test.mp3', {'bitrate': 8000, 'length': 267.70285714285717}), + ('test.ogg', {'bitrate': 128000, 'length': 229.18304166666667}), +]) +def test_get_audio_file_data(name, expected): + path = os.path.join(DATA_DIR, name) + with open(path, 'rb') as f: + result = utils.get_audio_file_data(f) + + assert result == expected diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index b22ab7fd504fe1afd54b713cec55d9912e9890d8..030fc3a73eeeabaf0507d5bdbc9949e3d5c4bff5 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -8,6 +8,14 @@ from funkwhale_api.music import views from funkwhale_api.federation import actors +@pytest.mark.parametrize('view,permissions', [ + (views.ImportBatchViewSet, ['library']), + (views.ImportJobViewSet, ['library']), +]) +def test_permissions(assert_user_permission, view, permissions): + assert_user_permission(view, permissions) + + @pytest.mark.parametrize('param,expected', [ ('true', 'full'), ('false', 'empty'), @@ -104,6 +112,24 @@ def test_serve_file_in_place( assert response[headers[proxy]] == expected +@pytest.mark.parametrize('proxy,serve_path,expected', [ + ('apache2', '/host/music', '/host/music/hello/worldéà .mp3'), + ('apache2', '/app/music', '/app/music/hello/worldéà .mp3'), + ('nginx', '/host/music', '/_protected/music/hello/worldéà .mp3'), + ('nginx', '/app/music', '/_protected/music/hello/worldéà .mp3'), +]) +def test_serve_file_in_place_utf8( + proxy, serve_path, expected, factories, api_client, settings): + settings.PROTECT_AUDIO_FILES = False + settings.PROTECT_FILE_PATH = '/_protected/music' + settings.REVERSE_PROXY_TYPE = proxy + settings.MUSIC_DIRECTORY_PATH = '/app/music' + settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path + path = views.get_file_path('/app/music/hello/worldéà .mp3') + + assert path == expected.encode('utf-8') + + @pytest.mark.parametrize('proxy,serve_path,expected', [ ('apache2', '/host/music', '/host/media/tracks/hello/world.mp3'), # apache with container not supported yet diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py index 6da9dd12e2e273643cf78ec0410535e77891fdad..ad9f739a1dd63ae2036142b2bdab149dc9e63635 100644 --- a/api/tests/subsonic/test_serializers.py +++ b/api/tests/subsonic/test_serializers.py @@ -77,7 +77,8 @@ def test_get_album_serializer(factories): artist = factories['music.Artist']() album = factories['music.Album'](artist=artist) track = factories['music.Track'](album=album) - tf = factories['music.TrackFile'](track=track) + tf = factories['music.TrackFile']( + track=track, bitrate=42000, duration=43, size=44) expected = { 'id': album.pk, @@ -98,7 +99,9 @@ def test_get_album_serializer(factories): 'year': track.album.release_date.year, 'contentType': tf.mimetype, 'suffix': tf.extension or '', - 'duration': tf.duration or 0, + 'bitrate': 42, + 'duration': 43, + 'size': 44, 'created': track.creation_date, 'albumId': album.pk, 'artistId': artist.pk, @@ -177,7 +180,8 @@ def test_playlist_detail_serializer(factories): def test_directory_serializer_artist(factories): track = factories['music.Track']() - tf = factories['music.TrackFile'](track=track) + tf = factories['music.TrackFile']( + track=track, bitrate=42000, duration=43, size=44) album = track.album artist = track.artist @@ -195,7 +199,9 @@ def test_directory_serializer_artist(factories): 'year': track.album.release_date.year, 'contentType': tf.mimetype, 'suffix': tf.extension or '', - 'duration': tf.duration or 0, + 'bitrate': 42, + 'duration': 43, + 'size': 44, 'created': track.creation_date, 'albumId': album.pk, 'artistId': artist.pk, diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index c7cd12e9e3ba19a457fa0d66d68e6b3679925c84..49199e0a781b990268431f109ac0c8804f8391ea 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -1,3 +1,7 @@ +import pytest + +from funkwhale_api.users import models + def test__str__(factories): user = factories['users.User'](username='hello') @@ -16,3 +20,33 @@ def test_changing_password_updates_subsonic_api_token(factories): assert user.subsonic_api_token is not None assert user.subsonic_api_token != 'test' + + +def test_get_permissions_superuser(factories): + user = factories['users.User'](is_superuser=True) + + perms = user.get_permissions() + for p in models.PERMISSIONS: + assert perms[p] is True + + +def test_get_permissions_regular(factories): + user = factories['users.User'](permission_library=True) + + perms = user.get_permissions() + for p in models.PERMISSIONS: + if p == 'library': + assert perms[p] is True + else: + assert perms[p] is False + + +@pytest.mark.parametrize('args,perms,expected', [ + ({'is_superuser': True}, ['federation', 'library'], True), + ({'is_superuser': False}, ['federation'], False), + ({'permission_library': True}, ['library'], True), + ({'permission_library': True}, ['library', 'federation'], False), +]) +def test_has_permissions(args, perms, expected, factories): + user = factories['users.User'](**args) + assert user.has_permissions(*perms) is expected diff --git a/api/tests/users/test_permissions.py b/api/tests/users/test_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..1564c761db59239eee5169bffd6ee92c2c148301 --- /dev/null +++ b/api/tests/users/test_permissions.py @@ -0,0 +1,56 @@ +import pytest +from rest_framework.views import APIView + +from funkwhale_api.users import permissions + + +def test_has_user_permission_no_user(api_request): + view = APIView.as_view() + permission = permissions.HasUserPermission() + request = api_request.get('/') + assert permission.has_permission(request, view) is False + + +def test_has_user_permission_anonymous(anonymous_user, api_request): + view = APIView.as_view() + permission = permissions.HasUserPermission() + request = api_request.get('/') + setattr(request, 'user', anonymous_user) + assert permission.has_permission(request, view) is False + + +@pytest.mark.parametrize('value', [True, False]) +def test_has_user_permission_logged_in_single(value, factories, api_request): + user = factories['users.User'](permission_federation=value) + + class View(APIView): + required_permissions = ['federation'] + view = View() + permission = permissions.HasUserPermission() + request = api_request.get('/') + setattr(request, 'user', user) + result = permission.has_permission(request, view) + assert result == user.has_permissions('federation') == value + + +@pytest.mark.parametrize('federation,library,expected', [ + (True, False, False), + (False, True, False), + (False, False, False), + (True, True, True), +]) +def test_has_user_permission_logged_in_single( + federation, library, expected, factories, api_request): + user = factories['users.User']( + permission_federation=federation, + permission_library=library, + ) + + class View(APIView): + required_permissions = ['federation', 'library'] + view = View() + permission = permissions.HasUserPermission() + request = api_request.get('/') + setattr(request, 'user', user) + result = permission.has_permission(request, view) + assert result == user.has_permissions('federation', 'library') == expected diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index fffc762fde7cb6f6533f3781bc73e828fa8e5d56..1bbf8b9a2d065735a017802bba369e817d016992 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -53,33 +53,24 @@ def test_can_disable_registration_view(preferences, client, db): assert response.status_code == 403 -def test_can_fetch_data_from_api(client, factories): +def test_can_fetch_data_from_api(api_client, factories): url = reverse('api:v1:users:users-me') - response = client.get(url) + response = api_client.get(url) # login required assert response.status_code == 401 user = factories['users.User']( - is_staff=True, - perms=[ - 'music.add_importbatch', - 'dynamic_preferences.change_globalpreferencemodel', - ] + permission_library=True ) - assert user.has_perm('music.add_importbatch') - client.login(username=user.username, password='test') - response = client.get(url) + api_client.login(username=user.username, password='test') + response = api_client.get(url) assert response.status_code == 200 - - payload = json.loads(response.content.decode('utf-8')) - - assert payload['username'] == user.username - assert payload['is_staff'] == user.is_staff - assert payload['is_superuser'] == user.is_superuser - assert payload['email'] == user.email - assert payload['name'] == user.name - assert payload['permissions']['import.launch']['status'] - assert payload['permissions']['settings.change']['status'] + assert response.data['username'] == user.username + assert response.data['is_staff'] == user.is_staff + assert response.data['is_superuser'] == user.is_superuser + assert response.data['email'] == user.email + assert response.data['name'] == user.name + assert response.data['permissions'] == user.get_permissions() def test_can_get_token_via_api(client, factories): @@ -202,6 +193,8 @@ def test_user_can_get_new_subsonic_token(logged_in_api_client): assert response.data == { 'subsonic_api_token': 'test' } + + def test_user_can_request_new_subsonic_token(logged_in_api_client): user = logged_in_api_client.user user.subsonic_api_token = 'test' diff --git a/docs/configuration.rst b/docs/configuration.rst index bbc658e087977e5eaa7c3f53598fdad99d68b4aa..46756bb266ccf918314f57b8ef8b01dcb68409ce 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -27,15 +27,24 @@ Those settings are stored in database and do not require a restart of your instance after modification. They typically relate to higher level configuration, such your instance description, signup policy and so on. -There is no polished interface for those settings, yet, but you can view update -them using the administration interface provided by Django (the framework funkwhale is built on). - -The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course). +You can edit those settings directly from the web application, assuming +you have the required permissions. The URL is ``/manage/settings``, and +you will also find a link to this page in the sidebar. If you plan to use acoustid and external imports (e.g. with the youtube backends), you should edit the corresponding settings in this interface. +.. note:: + + If you have any issue with the web application, a management interface is also + available for those settings from Django's administration interface. It's + less user friendly, though, and we recommend you use the web app interface + whenever possible. + + The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (prepend your domain in front of it, of course). + + Configuration reference ----------------------- @@ -108,3 +117,28 @@ Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be On non-docker setup, you don't need to configure this setting. .. note:: This path should not include any trailing slash + +User permissions +---------------- + +Funkwhale's permission model works as follows: + +- Anonymous users cannot do anything unless configured specifically +- Logged-in users can use the application, but cannot do things that affect + the whole instance +- Superusers can do anything + +To make things more granular and allow some delegation of responsability, +superusers can grant specific permissions to specific users. Available +permissions are: + +- **Manage instance-level settings**: users with this permission can edit instance + settings as described in :ref:`instance-settings` +- **Manage library**: users with this permission can import new music in the + instance +- **Manage library federation**: users with this permission can ask to federate with + other instances, and accept/deny federation requests from other intances + +There is no dedicated interface to manage users permissions, but superusers +can login on the Django's admin at ``/api/admin/`` and grant permissions +to users at ``/api/admin/users/user/``. diff --git a/front/src/audio/formats.js b/front/src/audio/formats.js index f6e2157a15d6c64afff10addd30ceba724dcdc65..d8a5a412546433ce73d5641c0aee075a480aba75 100644 --- a/front/src/audio/formats.js +++ b/front/src/audio/formats.js @@ -5,6 +5,7 @@ export default { ], formatsMap: { 'audio/ogg': 'ogg', - 'audio/mpeg': 'mp3' + 'audio/mpeg': 'mp3', + 'audio/x-flac': 'flac' } } diff --git a/front/src/components/About.vue b/front/src/components/About.vue index 524191250cf920fdbe4991381f827c1e50dd5ecd..59c8411ac131fe65ec72fa6ed1e4769eed5b361a 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -13,6 +13,12 @@ <p v-if="!instance.short_description.value && !instance.long_description.value"> {{ $t('Unfortunately, owners of this instance did not yet take the time to complete this page.') }} </p> + <router-link + class="ui button" + v-if="$store.state.auth.availablePermissions['settings']" + :to="{path: '/manage/settings', hash: 'instance'}"> + <i class="pencil icon"></i>{{ $t('Edit instance info') }} + </router-link> <div v-if="instance.short_description.value" class="ui middle aligned stackable text container"> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 97c743bbe804a5ca64bfe39ce69ad5b725745365..9f3134c2a1cd38a64a1761c505eac57fa6970666 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -60,7 +60,7 @@ <div class="menu"> <router-link class="item" - v-if="$store.state.auth.availablePermissions['import.launch']" + v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'library.requests', query: {status: 'pending' }}"> <i class="download icon"></i>{{ $t('Import requests') }} <div @@ -70,7 +70,7 @@ </router-link> <router-link class="item" - v-if="$store.state.auth.availablePermissions['federation.manage']" + v-if="$store.state.auth.availablePermissions['federation']" :to="{path: '/manage/federation/libraries'}"> <i class="sitemap icon"></i>{{ $t('Federation') }} <div @@ -78,6 +78,12 @@ :title="$t('Pending follow requests')"> {{ notifications.federation }}</div> </router-link> + <router-link + class="item" + v-if="$store.state.auth.availablePermissions['settings']" + :to="{path: '/manage/settings'}"> + <i class="settings icon"></i>{{ $t('Settings') }} + </router-link> </div> </div> </div> @@ -186,8 +192,8 @@ export default { }), showAdmin () { let adminPermissions = [ - this.$store.state.auth.availablePermissions['federation.manage'], - this.$store.state.auth.availablePermissions['import.launch'] + this.$store.state.auth.availablePermissions['federation'], + this.$store.state.auth.availablePermissions['library'] ] return adminPermissions.filter(e => { return e @@ -203,7 +209,7 @@ export default { this.fetchFederationImportRequestsCount() }, fetchFederationNotificationsCount () { - if (!this.$store.state.auth.availablePermissions['federation.manage']) { + if (!this.$store.state.auth.availablePermissions['federation']) { return } let self = this @@ -212,12 +218,11 @@ export default { }) }, fetchFederationImportRequestsCount () { - if (!this.$store.state.auth.availablePermissions['import.launch']) { + if (!this.$store.state.auth.availablePermissions['library']) { return } let self = this axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => { - console.log('YOLo') self.notifications.importRequests = response.data.count }) }, @@ -256,7 +261,6 @@ export default { }, '$store.state.availablePermissions': { handler () { - console.log('YOLO') this.fetchNotificationsCount() }, deep: true diff --git a/front/src/components/admin/SettingsGroup.vue b/front/src/components/admin/SettingsGroup.vue new file mode 100644 index 0000000000000000000000000000000000000000..255f04488973fbc4eb5c1ecc5e97418294c258ff --- /dev/null +++ b/front/src/components/admin/SettingsGroup.vue @@ -0,0 +1,120 @@ +<template> + <form :id="group.id" class="ui form" @submit.prevent="save"> + <div class="ui divider" /> + <h3 class="ui header">{{ group.label }}</h3> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header">{{ $t('Error while saving settings') }}</div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div v-if="result" class="ui positive message"> + {{ $t('Settings updated successfully.') }} + </div> + <p v-if="group.help">{{ group.help }}</p> + <div v-for="setting in settings" class="ui field"> + <template v-if="setting.field.widget.class !== 'CheckboxInput'"> + <label :for="setting.identifier">{{ setting.verbose_name }}</label> + <p v-if="setting.help_text">{{ setting.help_text }}</p> + </template> + <input + :id="setting.identifier" + v-if="setting.field.widget.class === 'PasswordInput'" + type="password" + class="ui input" + v-model="values[setting.identifier]" /> + <input + :id="setting.identifier" + v-if="setting.field.widget.class === 'TextInput'" + type="text" + class="ui input" + v-model="values[setting.identifier]" /> + <input + :id="setting.identifier" + v-if="setting.field.class === 'IntegerField'" + type="number" + class="ui input" + v-model.number="values[setting.identifier]" /> + <textarea + :id="setting.identifier" + v-else-if="setting.field.widget.class === 'Textarea'" + type="text" + class="ui input" + v-model="values[setting.identifier]" /> + <div v-else-if="setting.field.widget.class === 'CheckboxInput'" class="ui toggle checkbox"> + <input + :id="setting.identifier" + :name="setting.identifier" + v-model="values[setting.identifier]" + type="checkbox" /> + <label :for="setting.identifier">{{ setting.verbose_name }}</label> + <p v-if="setting.help_text">{{ setting.help_text }}</p> + </div> + </div> + <button + type="submit" + :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']"> + {{ $t('Save') }} + </button> + </form> +</template> + +<script> +import axios from 'axios' + +export default { + props: { + group: {type: Object, required: true}, + settingsData: {type: Array, required: true} + }, + data () { + return { + values: {}, + result: null, + errors: [], + isLoading: false + } + }, + created () { + let self = this + this.settings.forEach(e => { + self.values[e.identifier] = e.value + }) + }, + methods: { + save () { + let self = this + this.isLoading = true + self.errors = [] + self.result = null + axios.post('instance/admin/settings/bulk/', self.values).then((response) => { + self.result = true + self.isLoading = false + self.$store.dispatch('instance/fetchSettings') + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + }, + computed: { + settings () { + let byIdentifier = {} + this.settingsData.forEach(e => { + byIdentifier[e.identifier] = e + }) + return this.group.settings.map(e => { + return byIdentifier[e] + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> + +.ui.checkbox p { + margin-top: 1rem; +} +</style> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 2662f30b33a5321a1e2d0121c3930c46b5f66097..efa59a29d5c65fe30b88aedc5f453b8e9a250a61 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,8 +1,9 @@ <template> - <div :class="['ui', {'tiny': discrete}, 'buttons']"> + <div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']"> <button :title="$t('Add to current queue')" @click="addNext(true)" + :disabled="!playable" :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']"> <i class="ui play icon"></i> <template v-if="!discrete"><slot><i18next path="Play"/></slot></template> @@ -10,9 +11,9 @@ <div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']"> <i class="dropdown icon"></i> <div class="menu"> - <div class="item"@click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div> - <div class="item"@click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div> - <div class="item"@click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div> + <div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><i18next path="Add to queue"/></div> + <div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><i18next path="Play next"/></div> + <div class="item" :disabled="!playable" @click="addNext(true)"><i class="arrow down icon"></i><i18next path="Play now"/></div> </div> </div> </div> @@ -45,9 +46,18 @@ export default { jQuery(this.$el).find('.ui.dropdown').dropdown() }, computed: { + title () { + if (this.playable) { + return this.$t('Play immediatly') + } else { + if (this.track) { + return this.$t('This track is not imported and cannot be played') + } + } + }, playable () { if (this.track) { - return true + return this.track.files.length > 0 } else if (this.tracks) { return this.tracks.length > 0 } else if (this.playlist) { diff --git a/front/src/components/federation/LibraryFollowTable.vue b/front/src/components/federation/LibraryFollowTable.vue index fd16d83710cc956def2a8ce46a1b0e7d4b3506c9..4a7fe59f06c483d940a80d0cd57d89f8916b794f 100644 --- a/front/src/components/federation/LibraryFollowTable.vue +++ b/front/src/components/federation/LibraryFollowTable.vue @@ -55,7 +55,7 @@ <p slot="modal-confirm"><i18next path="Deny"/></p> </dangerous-button> <dangerous-button v-if="follow.approved !== true" class="tiny basic labeled icon" color='green' @confirm="updateFollow(follow, true)"> - <i class="x icon"></i> <i18next path="Approve"/> + <i class="check icon"></i> <i18next path="Approve"/> <p slot="modal-header"><i18next path="Approve access?"/></p> <p slot="modal-content"> <i18next path="By confirming, {%0%}@{%1%} will be granted access to your library."> diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index 820d60a5438545f1af49bd9bcf597b8c227534b9..e360ccb1c5c80985e5adc33f203ff316c610f504 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -13,10 +13,10 @@ exact> <i18next path="Requests"/> </router-link> - <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact> + <router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/launch" exact> <i18next path="Import"/> </router-link> - <router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches"> + <router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/batches"> <i18next path="Import batches"/> </router-link> </div> diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 940086e02aa9263bcab6924a75c161aacb5aa5dd..155a1245a58c413169059a5db988b88d2c8e7b6e 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -44,6 +44,46 @@ </a> </div> </div> + <div v-if="file" class="ui vertical stripe center aligned segment"> + <h2 class="ui header">{{ $t('Track information') }}</h2> + <table class="ui very basic collapsing celled center aligned table"> + <tbody> + <tr> + <td> + {{ $t('Duration') }} + </td> + <td v-if="file.duration"> + {{ time.parse(file.duration) }} + </td> + <td v-else> + {{ $t('N/A') }} + </td> + </tr> + <tr> + <td> + {{ $t('Size') }} + </td> + <td v-if="file.size"> + {{ file.size | humanSize }} + </td> + <td v-else> + {{ $t('N/A') }} + </td> + </tr> + <tr> + <td> + {{ $t('Bitrate') }} + </td> + <td v-if="file.bitrate"> + {{ file.bitrate | humanSize }}/s + </td> + <td v-else> + {{ $t('N/A') }} + </td> + </tr> + </tbody> + </table> + </div> <div class="ui vertical stripe center aligned segment"> <h2><i18next path="Lyrics"/></h2> <div v-if="isLoadingLyrics" class="ui vertical segment"> @@ -64,6 +104,8 @@ </template> <script> + +import time from '@/utils/time' import axios from 'axios' import url from '@/utils/url' import logger from '@/logging' @@ -83,6 +125,7 @@ export default { }, data () { return { + time, isLoadingTrack: true, isLoadingLyrics: true, track: null, @@ -134,6 +177,9 @@ export default { return u } }, + file () { + return this.track.files[0] + }, lyricsSearchUrl () { let base = 'http://lyrics.wikia.com/wiki/Special:Search?query=' let query = this.track.artist.name + ' ' + this.track.title @@ -159,5 +205,8 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped lang="scss"> - +.table.center.aligned { + margin-left: auto; + margin-right: auto; +} </style> diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue index 404948dc0793cfce4a9515bfde6b76c95afa17a9..da25241ce55dd697f2039797ce8e7d372e5b5ab3 100644 --- a/front/src/components/playlists/PlaylistModal.vue +++ b/front/src/components/playlists/PlaylistModal.vue @@ -7,9 +7,7 @@ <div class="description"> <template v-if="track"> <h4 class="ui header">{{ $t('Current track') }}</h4> - <div> - {{ $t('"{%title%}" by {%artist%}', { title: track.title, artist: track.artist.name }) }} - </div> + <div v-html='trackDisplay'></div> <div class="ui divider"></div> </template> @@ -112,6 +110,12 @@ export default { let p = _.sortBy(this.playlists, [(e) => { return e.modification_date }]) p.reverse() return p + }, + trackDisplay () { + return this.$t('"{%title%}" by {%artist%}', { + title: this.track.title, + artist: this.track.artist.name } + ) } }, watch: { diff --git a/front/src/components/requests/Card.vue b/front/src/components/requests/Card.vue index 9d0bf0b771bdaa398c882e84be4f07afb642c6ce..7743bb6d4403653fedbc355983ec0bb4e7adecf1 100644 --- a/front/src/components/requests/Card.vue +++ b/front/src/components/requests/Card.vue @@ -22,7 +22,7 @@ </span> <button @click="createImport" - v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']" + v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['library']" class="ui mini basic green right floated button">{{ $t('Create import') }}</button> </div> diff --git a/front/src/filters.js b/front/src/filters.js index afc393d402e8f80e046d2bd4443e3f03da2f07a7..11751559961c393b9d5bb3369aba199f8badf34a 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -47,4 +47,23 @@ export function capitalize (str) { Vue.filter('capitalize', capitalize) +export function humanSize (bytes) { + let si = true + var thresh = si ? 1000 : 1024 + if (Math.abs(bytes) < thresh) { + return bytes + ' B' + } + var units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] + var u = -1 + do { + bytes /= thresh + ++u + } while (Math.abs(bytes) >= thresh && u < units.length - 1) + return bytes.toFixed(1) + ' ' + units[u] +} + +Vue.filter('humanSize', humanSize) + export default {} diff --git a/front/src/main.js b/front/src/main.js index 5481615f2006025cea7e009dc9ddd8a49b97623f..2e92fbbd2243e20f567f47dcbcc5e5f3c7937659 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -35,8 +35,26 @@ Vue.use(VueMasonryPlugin) Vue.use(VueLazyload) Vue.config.productionTip = false Vue.directive('title', { - inserted: (el, binding) => { document.title = binding.value + ' - Funkwhale' }, - updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' } + inserted: (el, binding) => { + let parts = [] + let instanceName = store.state.instance.settings.instance.name.value + if (instanceName.length === 0) { + instanceName = 'Funkwhale' + } + parts.unshift(instanceName) + parts.unshift(binding.value) + document.title = parts.join(' - ') + }, + updated: (el, binding) => { + let parts = [] + let instanceName = store.state.instance.settings.instance.name.value + if (instanceName.length === 0) { + instanceName = 'Funkwhale' + } + parts.unshift(instanceName) + parts.unshift(binding.value) + document.title = parts.join(' - ') + } }) axios.defaults.baseURL = config.API_URL diff --git a/front/src/router/index.js b/front/src/router/index.js index b1e208023335945f42c7b255aa05ebaffeac5e2b..f71dab7f92decf05f5df0ca2129207bf7830fb48 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -28,6 +28,7 @@ import RequestsList from '@/components/requests/RequestsList' import PlaylistDetail from '@/views/playlists/Detail' import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' +import AdminSettings from '@/views/admin/Settings' import FederationBase from '@/views/federation/Base' import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' @@ -117,6 +118,11 @@ export default new Router({ defaultPaginateBy: route.query.paginateBy }) }, + { + path: '/manage/settings', + name: 'manage.settings', + component: AdminSettings + }, { path: '/manage/federation', component: FederationBase, diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 68a15090b5c289d2825563743f4cad7f2d3cdbf0..87af081d26fdfd09d2d02b16db6196f91d6cee95 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -112,7 +112,7 @@ export default { dispatch('playlists/fetchOwn', null, {root: true}) Object.keys(data.permissions).forEach(function (key) { // this makes it easier to check for permissions in templates - commit('permission', {key, status: data.permissions[String(key)].status}) + commit('permission', {key, status: data.permissions[String(key)]}) }) return response.data }, (response) => { diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue new file mode 100644 index 0000000000000000000000000000000000000000..7174ab516c1e438d0aa09b0491e803b26f5f4316 --- /dev/null +++ b/front/src/views/admin/Settings.vue @@ -0,0 +1,155 @@ +<template> + <div class="main pusher" v-title="$t('Instance settings')"> + <div class="ui vertical stripe segment"> + <div class="ui text container"> + <div :class="['ui', {'loading': isLoading}, 'form']"></div> + <div id="settings-grid" v-if="settingsData" class="ui grid"> + <div class="twelve wide stretched column"> + <settings-group + :settings-data="settingsData" + :group="group" + :key="group.title" + v-for="group in groups" /> + </div> + <div class="four wide column"> + <div class="ui sticky vertical secondary menu"> + <div class="header item">{{ $t('Sections') }}</div> + <a :class="['menu', {active: group.id === current}, 'item']" + @click.prevent="scrollTo(group.id)" + :href="'#' + group.id" + v-for="group in groups">{{ group.label }}</a> + </div> + </div> + </div> + + </div> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import $ from 'jquery' + +import SettingsGroup from '@/components/admin/SettingsGroup' + +export default { + components: { + SettingsGroup + }, + data () { + return { + isLoading: false, + settingsData: null, + current: null + } + }, + created () { + let self = this + this.fetchSettings().then(r => { + self.$nextTick(() => { + if (self.$store.state.route.hash) { + self.scrollTo(self.$store.state.route.hash.substr(1)) + } + }) + }) + }, + methods: { + scrollTo (id) { + console.log(id, 'hello') + this.current = id + document.getElementById(id).scrollIntoView() + }, + fetchSettings () { + let self = this + self.isLoading = true + return axios.get('instance/admin/settings/').then((response) => { + self.settingsData = response.data + self.isLoading = false + }) + } + }, + computed: { + groups () { + return [ + { + label: this.$t('Instance information'), + id: 'instance', + settings: [ + 'instance__name', + 'instance__short_description', + 'instance__long_description' + ] + }, + { + label: this.$t('Users'), + id: 'users', + settings: [ + 'users__registration_enabled', + 'common__api_authentication_required' + ] + }, + { + label: this.$t('Imports'), + id: 'imports', + settings: [ + 'providers_youtube__api_key', + 'providers_acoustid__api_key' + ] + }, + { + label: this.$t('Playlists'), + id: 'playlists', + settings: [ + 'playlists__max_tracks' + ] + }, + { + label: this.$t('Federation'), + id: 'federation', + settings: [ + 'federation__enabled', + 'federation__music_needs_approval', + 'federation__collection_page_size', + 'federation__music_cache_duration', + 'federation__actor_fetch_delay' + ] + }, + { + label: this.$t('Subsonic'), + id: 'subsonic', + settings: [ + 'subsonic__enabled' + ] + }, + { + label: this.$t('Statistics'), + id: 'statistics', + settings: [ + 'instance__nodeinfo_enabled', + 'instance__nodeinfo_stats_enabled', + 'instance__nodeinfo_private' + ] + }, + { + label: this.$t('Error reporting'), + id: 'reporting', + settings: [ + 'raven__front_enabled', + 'raven__front_dsn' + + ] + } + ] + } + }, + watch: { + settingsData () { + let self = this + this.$nextTick(() => { + $(self.$el).find('.sticky').sticky({context: '#settings-grid'}) + }) + } + } +} +</script> diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index 32ee5aafaa9d28e34d1c824f479c6b0efea0447f..5001fb14db9db32bd129e0fdcefa3a04f60a848b 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -76,7 +76,6 @@ export default { Pagination }, data () { - console.log('YOLO', this.$t) let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') return { isLoading: true, diff --git a/front/test/unit/specs/store/auth.spec.js b/front/test/unit/specs/store/auth.spec.js index 3d175e9f9fa0e31b3b51b79314fe182e0516549c..46901cd16edd25ee9be4f7137a8835f1336174fb 100644 --- a/front/test/unit/specs/store/auth.spec.js +++ b/front/test/unit/specs/store/auth.spec.js @@ -164,9 +164,7 @@ describe('store/auth', () => { const profile = { username: 'bob', permissions: { - admin: { - status: true - } + admin: true } } moxios.stubRequest('users/users/me/', {