From 815d7293675dc717fe7064b4e8bac689f31b9ee2 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 19 Dec 2018 14:03:21 +0100 Subject: [PATCH 1/3] Fix #578: added embed.html page to power iframe widget --- changes/changelog.d/578.feature | 48 ++ dev.yml | 42 +- docker/nginx/conf.dev | 49 +- docker/nginx/entrypoint.sh | 20 +- front/package.json | 1 + front/public/embed.html | 20 + front/src/Embed.vue | 567 ++++++++++++++++++++++ front/src/assets/embed/default-cover.jpeg | Bin 0 -> 8991 bytes front/src/components/Logo.vue | 16 +- front/src/embed.js | 16 + front/src/utils/time.js | 8 + front/vue.config.js | 46 +- front/yarn.lock | 43 ++ 13 files changed, 797 insertions(+), 79 deletions(-) create mode 100644 changes/changelog.d/578.feature create mode 100644 front/public/embed.html create mode 100644 front/src/Embed.vue create mode 100644 front/src/assets/embed/default-cover.jpeg create mode 100644 front/src/embed.js diff --git a/changes/changelog.d/578.feature b/changes/changelog.d/578.feature new file mode 100644 index 000000000..9ab4fcf67 --- /dev/null +++ b/changes/changelog.d/578.feature @@ -0,0 +1,48 @@ +Allow embedding of albums and tracks available in public libraries via an '.format( + data["width"], + data["height"], + settings.FUNKWHALE_EMBED_URL + + "?type={}&id={}".format(embed_type, embed_id), + ) + return data + + def create(self, data): + return data diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py new file mode 100644 index 000000000..c1476e8a9 --- /dev/null +++ b/api/funkwhale_api/music/spa_views.py @@ -0,0 +1,161 @@ +from django.conf import settings +from django.urls import reverse + +from funkwhale_api.common import utils + +from . import models + + +def library_track(request, pk): + queryset = models.Track.objects.filter(pk=pk).select_related("album", "artist") + try: + obj = queryset.get() + except models.Track.DoesNotExist: + return [] + track_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_track", kwargs={"pk": obj.pk}), + ) + metas = [ + {"tag": "meta", "property": "og:url", "content": track_url}, + {"tag": "meta", "property": "og:title", "content": obj.title}, + {"tag": "meta", "property": "og:type", "content": "music.song"}, + {"tag": "meta", "property": "music:album:disc", "content": obj.disc_number}, + {"tag": "meta", "property": "music:album:track", "content": obj.position}, + { + "tag": "meta", + "property": "music:musician", + "content": utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}), + ), + }, + { + "tag": "meta", + "property": "music:album", + "content": utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}), + ), + }, + ] + if obj.album.cover: + metas.append( + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url(settings.FUNKWHALE_URL, obj.album.cover.url), + } + ) + + if obj.uploads.playable_by(None).exists(): + metas.append( + { + "tag": "meta", + "property": "og:audio", + "content": utils.join_url(settings.FUNKWHALE_URL, obj.listen_url), + } + ) + metas.append( + { + "tag": "link", + "rel": "alternate", + "type": "application/json+oembed", + "href": ( + utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + + "?url={}".format(track_url) + ), + } + ) + return metas + + +def library_album(request, pk): + queryset = models.Album.objects.filter(pk=pk).select_related("artist") + try: + obj = queryset.get() + except models.Album.DoesNotExist: + return [] + album_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_album", kwargs={"pk": obj.pk}), + ) + metas = [ + {"tag": "meta", "property": "og:url", "content": album_url}, + {"tag": "meta", "property": "og:title", "content": obj.title}, + {"tag": "meta", "property": "og:type", "content": "music.album"}, + { + "tag": "meta", + "property": "music:musician", + "content": utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}), + ), + }, + ] + + if obj.release_date: + metas.append( + { + "tag": "meta", + "property": "music:release_date", + "content": str(obj.release_date), + } + ) + + if obj.cover: + metas.append( + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url(settings.FUNKWHALE_URL, obj.cover.url), + } + ) + + if models.Upload.objects.filter(track__album=obj).playable_by(None).exists(): + metas.append( + { + "tag": "link", + "rel": "alternate", + "type": "application/json+oembed", + "href": ( + utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + + "?url={}".format(album_url) + ), + } + ) + return metas + + +def library_artist(request, pk): + queryset = models.Artist.objects.filter(pk=pk) + try: + obj = queryset.get() + except models.Artist.DoesNotExist: + return [] + artist_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_artist", kwargs={"pk": obj.pk}), + ) + # we use latest album's cover as artist image + latest_album = ( + obj.albums.exclude(cover="").exclude(cover=None).order_by("release_date").last() + ) + metas = [ + {"tag": "meta", "property": "og:url", "content": artist_url}, + {"tag": "meta", "property": "og:title", "content": obj.name}, + {"tag": "meta", "property": "og:type", "content": "profile"}, + ] + + if latest_album and latest_album.cover: + metas.append( + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url( + settings.FUNKWHALE_URL, latest_album.cover.url + ), + } + ) + + return metas diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 19a00884b..1fcd782fb 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -508,3 +508,13 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet): except AttributeError: first_arg = [i.conf for i in instance_or_qs if i.conf] return super().get_serializer(*((first_arg,) + args[1:]), **kwargs) + + +class OembedView(views.APIView): + permission_classes = [common_permissions.ConditionalAuthentication] + + def get(self, request, *args, **kwargs): + serializer = serializers.OembedSerializer(data=request.GET) + serializer.is_valid(raise_exception=True) + embed_data = serializer.save() + return Response(embed_data) diff --git a/api/tests/common/test_middleware.py b/api/tests/common/test_middleware.py new file mode 100644 index 000000000..2ed875a53 --- /dev/null +++ b/api/tests/common/test_middleware.py @@ -0,0 +1,137 @@ +import pytest + +from funkwhale_api.common import middleware + + +def test_spa_fallback_middleware_no_404(mocker): + get_response = mocker.Mock() + get_response.return_value = mocker.Mock(status_code=200) + request = mocker.Mock(path="/") + m = middleware.SPAFallbackMiddleware(get_response) + + assert m(request) == get_response.return_value + + +def test_spa_middleware_calls_should_fallback_false(mocker): + get_response = mocker.Mock() + get_response.return_value = mocker.Mock(status_code=404) + should_falback = mocker.patch.object( + middleware, "should_fallback_to_spa", return_value=False + ) + request = mocker.Mock(path="/") + + m = middleware.SPAFallbackMiddleware(get_response) + + assert m(request) == get_response.return_value + should_falback.assert_called_once_with(request.path) + + +def test_spa_middleware_should_fallback_true(mocker): + get_response = mocker.Mock() + get_response.return_value = mocker.Mock(status_code=404) + request = mocker.Mock(path="/") + mocker.patch.object(middleware, "should_fallback_to_spa", return_value=True) + serve_spa = mocker.patch.object(middleware, "serve_spa") + m = middleware.SPAFallbackMiddleware(get_response) + + assert m(request) == serve_spa.return_value + serve_spa.assert_called_once_with(request) + + +@pytest.mark.parametrize( + "path,expected", + [("/", True), ("/federation", False), ("/api", False), ("/an/spa/path/", True)], +) +def test_should_fallback(path, expected, mocker): + assert middleware.should_fallback_to_spa(path) is expected + + +def test_serve_spa_from_cache(mocker, settings, preferences, no_api_auth): + + request = mocker.Mock(path="/") + get_spa_html = mocker.patch.object( + middleware, "get_spa_html", return_value="" + ) + mocker.patch.object( + middleware, + "get_default_head_tags", + return_value=[ + {"tag": "meta", "property": "og:title", "content": "default title"}, + {"tag": "meta", "property": "og:site_name", "content": "default site name"}, + ], + ) + get_request_head_tags = mocker.patch.object( + middleware, + "get_request_head_tags", + return_value=[ + {"tag": "meta", "property": "og:title", "content": "custom title"}, + { + "tag": "meta", + "property": "og:description", + "content": "custom description", + }, + ], + ) + response = middleware.serve_spa(request) + + assert response.status_code == 200 + expected = [ + "", + '', + '', + '', + "", + ] + get_spa_html.assert_called_once_with(settings.FUNKWHALE_SPA_HTML_ROOT) + get_request_head_tags.assert_called_once_with(request) + assert response.content == "\n".join(expected).encode() + + +def test_get_default_head_tags(preferences, settings): + settings.APP_NAME = "Funkwhale" + preferences["instance__name"] = "Hello" + preferences["instance__short_description"] = "World" + + expected = [ + {"tag": "meta", "property": "og:type", "content": "website"}, + {"tag": "meta", "property": "og:site_name", "content": "Hello - Funkwhale"}, + {"tag": "meta", "property": "og:description", "content": "World"}, + { + "tag": "meta", + "property": "og:image", + "content": settings.FUNKWHALE_URL + "/front/favicon.png", + }, + {"tag": "meta", "property": "og:url", "content": settings.FUNKWHALE_URL + "/"}, + ] + + assert middleware.get_default_head_tags("/") == expected + + +def test_get_spa_html_from_cache(local_cache): + local_cache.set("spa-html:http://test", "hello world") + + assert middleware.get_spa_html("http://test") == "hello world" + + +def test_get_spa_html_from_http(local_cache, r_mock, mocker, settings): + cache_set = mocker.spy(local_cache, "set") + url = "http://test" + r_mock.get(url + "/index.html", text="hello world") + + assert middleware.get_spa_html("http://test") == "hello world" + cache_set.assert_called_once_with( + "spa-html:{}".format(url), + "hello world", + settings.FUNKWHALE_SPA_HTML_CACHE_DURATION, + ) + + +def test_get_route_head_tags(mocker, settings): + match = mocker.Mock(args=[], kwargs={"pk": 42}, func=mocker.Mock()) + resolve = mocker.patch("django.urls.resolve", return_value=match) + request = mocker.Mock(path="/tracks/42") + tags = middleware.get_request_head_tags(request) + + assert tags == match.func.return_value + match.func.assert_called_once_with(request, *[], **{"pk": 42}) + resolve.assert_called_once_with(request.path, urlconf=settings.SPA_URLCONF) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index d880c8d6f..99317303c 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -13,7 +13,7 @@ import factory import pytest from django.contrib.auth.models import AnonymousUser -from django.core.cache import cache as django_cache +from django.core.cache import cache as django_cache, caches from django.core.files import uploadedfile from django.utils import timezone from django.test import client @@ -100,6 +100,12 @@ def cache(): django_cache.clear() +@pytest.fixture(autouse=True) +def local_cache(): + yield caches["local"] + caches["local"].clear() + + @pytest.fixture def factories(db): """ @@ -382,3 +388,15 @@ def temp_signal(mocker): @pytest.fixture() def stdout(): yield io.StringIO() + + +@pytest.fixture +def spa_html(r_mock, settings): + yield r_mock.get( + settings.FUNKWHALE_SPA_HTML_ROOT + "index.html", text="" + ) + + +@pytest.fixture +def no_api_auth(preferences): + preferences["common__api_authentication_required"] = False diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py new file mode 100644 index 000000000..a60da50df --- /dev/null +++ b/api/tests/music/test_spa_views.py @@ -0,0 +1,148 @@ +from django.urls import reverse + +from funkwhale_api.common import utils + + +def test_library_track(spa_html, no_api_auth, client, factories, settings): + track = factories["music.Upload"](playable=True, track__disc_number=1).track + url = "/library/tracks/{}".format(track.pk) + + response = client.get(url) + + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:title", "content": track.title}, + {"tag": "meta", "property": "og:type", "content": "music.song"}, + { + "tag": "meta", + "property": "music:album:disc", + "content": str(track.disc_number), + }, + { + "tag": "meta", + "property": "music:album:track", + "content": str(track.position), + }, + { + "tag": "meta", + "property": "music:musician", + "content": utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk}), + ), + }, + { + "tag": "meta", + "property": "music:album", + "content": utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_album", kwargs={"pk": track.album.pk}), + ), + }, + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url(settings.FUNKWHALE_URL, track.album.cover.url), + }, + { + "tag": "meta", + "property": "og:audio", + "content": utils.join_url(settings.FUNKWHALE_URL, track.listen_url), + }, + { + "tag": "link", + "rel": "alternate", + "type": "application/json+oembed", + "href": ( + utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + + "?url={}".format(utils.join_url(settings.FUNKWHALE_URL, url)) + ), + }, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas + + +def test_library_album(spa_html, no_api_auth, client, factories, settings): + track = factories["music.Upload"](playable=True, track__disc_number=1).track + album = track.album + url = "/library/albums/{}".format(album.pk) + + response = client.get(url) + + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:title", "content": album.title}, + {"tag": "meta", "property": "og:type", "content": "music.album"}, + { + "tag": "meta", + "property": "music:musician", + "content": utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk}), + ), + }, + { + "tag": "meta", + "property": "music:release_date", + "content": str(album.release_date), + }, + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url(settings.FUNKWHALE_URL, album.cover.url), + }, + { + "tag": "link", + "rel": "alternate", + "type": "application/json+oembed", + "href": ( + utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed")) + + "?url={}".format(utils.join_url(settings.FUNKWHALE_URL, url)) + ), + }, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas + + +def test_library_artist(spa_html, no_api_auth, client, factories, settings): + album = factories["music.Album"]() + artist = album.artist + url = "/library/artists/{}".format(artist.pk) + + response = client.get(url) + + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:title", "content": artist.name}, + {"tag": "meta", "property": "og:type", "content": "profile"}, + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url(settings.FUNKWHALE_URL, album.cover.url), + }, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 263b78784..d954787b0 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -6,8 +6,10 @@ import pytest from django.urls import reverse from django.utils import timezone -from funkwhale_api.music import licenses, models, serializers, tasks, views +from funkwhale_api.common import utils from funkwhale_api.federation import api_serializers as federation_api_serializers +from funkwhale_api.federation import utils as federation_utils +from funkwhale_api.music import licenses, models, serializers, tasks, views DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -570,3 +572,74 @@ def test_detail_license(api_client, preferences): response = api_client.get(url) assert response.data == expected + + +def test_oembed_track(factories, no_api_auth, api_client, settings, preferences): + settings.FUNKWHALE_URL = "http://test" + settings.FUNKWHALE_EMBED_URL = "http://embed" + preferences["instance__name"] = "Hello" + track = factories["music.Track"]() + url = reverse("api:v1:oembed") + track_url = "https://test.com/library/tracks/{}".format(track.pk) + iframe_src = "http://embed?type=track&id={}".format(track.pk) + expected = { + "version": 1.0, + "type": "rich", + "provider_name": "{} - {}".format( + preferences["instance__name"], settings.APP_NAME + ), + "provider_url": settings.FUNKWHALE_URL, + "height": 150, + "width": 600, + "title": "{} by {}".format(track.title, track.artist.name), + "description": track.full_name, + "thumbnail_url": federation_utils.full_url( + track.album.cover.crop["400x400"].url + ), + "html": ''.format( + iframe_src + ), + "author_name": track.artist.name, + "author_url": federation_utils.full_url( + utils.spa_reverse("library_artist", kwargs={"pk": track.artist.pk}) + ), + } + + response = api_client.get(url, {"url": track_url, "format": "json"}) + + assert response.data == expected + + +def test_oembed_album(factories, no_api_auth, api_client, settings, preferences): + settings.FUNKWHALE_URL = "http://test" + settings.FUNKWHALE_EMBED_URL = "http://embed" + preferences["instance__name"] = "Hello" + track = factories["music.Track"]() + album = track.album + url = reverse("api:v1:oembed") + album_url = "https://test.com/library/albums/{}".format(album.pk) + iframe_src = "http://embed?type=album&id={}".format(album.pk) + expected = { + "version": 1.0, + "type": "rich", + "provider_name": "{} - {}".format( + preferences["instance__name"], settings.APP_NAME + ), + "provider_url": settings.FUNKWHALE_URL, + "height": 400, + "width": 600, + "title": "{} by {}".format(album.title, album.artist.name), + "description": "{} by {}".format(album.title, album.artist.name), + "thumbnail_url": federation_utils.full_url(album.cover.crop["400x400"].url), + "html": ''.format( + iframe_src + ), + "author_name": album.artist.name, + "author_url": federation_utils.full_url( + utils.spa_reverse("library_artist", kwargs={"pk": album.artist.pk}) + ), + } + + response = api_client.get(url, {"url": album_url, "format": "json"}) + + assert response.data == expected diff --git a/deploy/docker.nginx.template b/deploy/docker.nginx.template index d73a1c4b6..1e2ab0014 100644 --- a/deploy/docker.nginx.template +++ b/deploy/docker.nginx.template @@ -24,17 +24,14 @@ server { root /frontend; location / { - try_files $uri $uri/ @rewrites; - } - - location @rewrites { - rewrite ^(.+)$ /index.html last; - } - location /api/ { include /etc/nginx/funkwhale_proxy.conf; # this is needed if you have file import via upload enabled client_max_body_size ${NGINX_MAX_BODY_SIZE}; - proxy_pass http://funkwhale-api/api/; + proxy_pass http://funkwhale-api/; + } + + location /front/ { + alias /frontend; } location /federation/ { diff --git a/deploy/nginx.template b/deploy/nginx.template index 1eb011d4e..f6bf61069 100644 --- a/deploy/nginx.template +++ b/deploy/nginx.template @@ -44,17 +44,14 @@ server { root ${FUNKWHALE_FRONTEND_PATH}; location / { - try_files $uri $uri/ @rewrites; - } - - location @rewrites { - rewrite ^(.+)$ /index.html last; - } - location /api/ { include /etc/nginx/funkwhale_proxy.conf; # this is needed if you have file import via upload enabled client_max_body_size ${NGINX_MAX_BODY_SIZE}; - proxy_pass http://funkwhale-api/api/; + proxy_pass http://funkwhale-api/; + } + + location /front/ { + alias ${FUNKWHALE_FRONTEND_PATH}; } location /federation/ { From 49bb4befc6d060645fc80a2d2acc6bb12bccdbec Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 19 Dec 2018 14:04:49 +0100 Subject: [PATCH 3/3] See #578: added embed wizard on track and album page to generate iframe embed code --- front/src/components/audio/EmbedWizard.vue | 80 +++++++++++++++++++ .../components/federation/LibraryWidget.vue | 1 + front/src/components/library/Album.vue | 41 +++++++++- front/src/components/library/Track.vue | 39 ++++++++- 4 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 front/src/components/audio/EmbedWizard.vue diff --git a/front/src/components/audio/EmbedWizard.vue b/front/src/components/audio/EmbedWizard.vue new file mode 100644 index 000000000..7a50ffa54 --- /dev/null +++ b/front/src/components/audio/EmbedWizard.vue @@ -0,0 +1,80 @@ + + + + + + diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue index ff73bb7a8..abe993e46 100644 --- a/front/src/components/federation/LibraryWidget.vue +++ b/front/src/components/federation/LibraryWidget.vue @@ -62,6 +62,7 @@ export default { self.nextPage = response.data.next self.isLoading = false self.libraries = response.data.results + self.$emit('loaded', self.libraries) }, error => { self.isLoading = false self.errors = error.backendErrors diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 9a4adfafc..6a3cfaa8e 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -37,6 +37,30 @@ View on MusicBrainz +