diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 16f21d3465c0a683e5885063e07b8544e6712da9..e6eeff31dd1a5c59f149f5c3a9c20d391f50929f 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -31,6 +31,7 @@ subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic" v1_patterns += [ + url(r"^oembed/$", views.OembedView.as_view(), name="oembed"), url( r"^instance/", include(("funkwhale_api.instance.urls", "instance"), namespace="instance"), diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 6a9501c0a298cad81481d9a5e773481f5344eb55..16efef696211f4fecddd19f5cfd309548fd6c9b0 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -70,7 +70,16 @@ else: FUNKWHALE_PROTOCOL = _parsed.scheme FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME) - +FUNKWHALE_SPA_HTML_ROOT = env( + "FUNKWHALE_SPA_HTML_ROOT", default=FUNKWHALE_URL + "/front/" +) +FUNKWHALE_SPA_HTML_CACHE_DURATION = env.int( + "FUNKWHALE_SPA_HTML_CACHE_DURATION", default=60 * 15 +) +FUNKWHALE_EMBED_URL = env( + "FUNKWHALE_EMBED_URL", default=FUNKWHALE_SPA_HTML_ROOT + "embed.html" +) +APP_NAME = "Funkwhale" # XXX: deprecated, see #186 FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True) @@ -159,7 +168,7 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS # MIDDLEWARE CONFIGURATION # ------------------------------------------------------------------------------ MIDDLEWARE = ( - # Make sure djangosecure.middleware.SecurityMiddleware is listed first + "funkwhale_api.common.middleware.SPAFallbackMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", @@ -305,6 +314,7 @@ FILE_UPLOAD_PERMISSIONS = 0o644 # URL Configuration # ------------------------------------------------------------------------------ ROOT_URLCONF = "config.urls" +SPA_URLCONF = "config.spa_urls" # See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application WSGI_APPLICATION = "config.wsgi.application" ASGI_APPLICATION = "config.routing.application" @@ -400,7 +410,13 @@ if AUTH_LDAP_ENABLED: AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify" CACHE_DEFAULT = "redis://127.0.0.1:6379/0" -CACHES = {"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT)} +CACHES = { + "default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT), + "local": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "local-cache", + }, +} CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache" diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..071965b048b30ffb9aaf15c80d63cc5249389d10 --- /dev/null +++ b/api/config/spa_urls.py @@ -0,0 +1,18 @@ +from django import urls + +from funkwhale_api.music import spa_views + + +urlpatterns = [ + urls.re_path( + r"^library/tracks/(?P<pk>\d+)/?$", spa_views.library_track, name="library_track" + ), + urls.re_path( + r"^library/albums/(?P<pk>\d+)/?$", spa_views.library_album, name="library_album" + ), + urls.re_path( + r"^library/artists/(?P<pk>\d+)/?$", + spa_views.library_artist, + name="library_artist", + ), +] diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..090d67c9e5c7a6370379649d1a0c3d4719d9c493 --- /dev/null +++ b/api/funkwhale_api/common/middleware.py @@ -0,0 +1,137 @@ +import html +import requests + +from django import http +from django.conf import settings +from django.core.cache import caches +from django import urls + +from . import preferences +from . import utils + +EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"] + + +def should_fallback_to_spa(path): + if path == "/": + return True + return not any([path.startswith(m) for m in EXCLUDED_PATHS]) + + +def serve_spa(request): + html = get_spa_html(settings.FUNKWHALE_SPA_HTML_ROOT) + head, tail = html.split("</head>", 1) + if not preferences.get("common__api_authentication_required"): + try: + request_tags = get_request_head_tags(request) or [] + except urls.exceptions.Resolver404: + # we don't have any custom tags for this route + request_tags = [] + else: + # API is not open, we don't expose any custom data + request_tags = [] + default_tags = get_default_head_tags(request.path) + unique_attributes = ["name", "property"] + + final_tags = request_tags + skip = [] + + for t in final_tags: + for attr in unique_attributes: + if attr in t: + skip.append(t[attr]) + for t in default_tags: + existing = False + for attr in unique_attributes: + if t.get(attr) in skip: + existing = True + break + if not existing: + final_tags.append(t) + + # let's inject our meta tags in the HTML + head += "\n" + "\n".join(render_tags(final_tags)) + "\n</head>" + + return http.HttpResponse(head + tail) + + +def get_spa_html(spa_url): + cache_key = "spa-html:{}".format(spa_url) + cached = caches["local"].get(cache_key) + if cached: + return cached + + response = requests.get( + utils.join_url(spa_url, "index.html"), + verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, + ) + response.raise_for_status() + content = response.text + caches["local"].set(cache_key, content, settings.FUNKWHALE_SPA_HTML_CACHE_DURATION) + return content + + +def get_default_head_tags(path): + instance_name = preferences.get("instance__name") + short_description = preferences.get("instance__short_description") + app_name = settings.APP_NAME + + parts = [instance_name, app_name] + + return [ + {"tag": "meta", "property": "og:type", "content": "website"}, + { + "tag": "meta", + "property": "og:site_name", + "content": " - ".join([p for p in parts if p]), + }, + {"tag": "meta", "property": "og:description", "content": short_description}, + { + "tag": "meta", + "property": "og:image", + "content": utils.join_url(settings.FUNKWHALE_URL, "/front/favicon.png"), + }, + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, path), + }, + ] + + +def render_tags(tags): + """ + Given a dict like {'tag': 'meta', 'hello': 'world'} + return a html ready tag like + <meta hello="world" /> + """ + for tag in tags: + + yield "<{tag} {attrs} />".format( + tag=tag.pop("tag"), + attrs=" ".join( + [ + '{}="{}"'.format(a, html.escape(str(v))) + for a, v in sorted(tag.items()) + if v + ] + ), + ) + + +def get_request_head_tags(request): + match = urls.resolve(request.path, urlconf=settings.SPA_URLCONF) + return match.func(request, *match.args, **match.kwargs) + + +class SPAFallbackMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + if response.status_code == 404 and should_fallback_to_spa(request.path): + return serve_spa(request) + + return response diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index deda2f5900e721db633fc52ea70b8da87bf5d6bb..81bc5c02694b934469cb9d665d5d01cb978cd520 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -3,9 +3,12 @@ from django.utils.deconstruct import deconstructible import os import shutil import uuid +import xml.etree.ElementTree as ET from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit +from django.conf import settings +from django import urls from django.db import transaction @@ -107,3 +110,32 @@ def chunk_queryset(source_qs, chunk_size): if nb_items < chunk_size: return + + +def join_url(start, end): + if start.endswith("/") and end.startswith("/"): + return start + end[1:] + + if not start.endswith("/") and not end.startswith("/"): + return start + "/" + end + + return start + end + + +def spa_reverse(name, args=[], kwargs={}): + return urls.reverse(name, urlconf=settings.SPA_URLCONF, args=args, kwargs=kwargs) + + +def spa_resolve(path): + return urls.resolve(path, urlconf=settings.SPA_URLCONF) + + +def parse_meta(html): + # dirty but this is only for testing so we don't really care, + # we convert the html string to xml so it can be parsed as xml + html = '<?xml version="1.0"?>' + html + tree = ET.fromstring(html) + + meta = [elem for elem in tree.iter() if elem.tag in ["meta", "link"]] + + return [dict([("tag", elem.tag)] + list(elem.items())) for elem in meta] diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 6157c5a75d7fb55328762dbefb8446a022b6d997..42e05765426af64d89787d75af4130b14c0d217e 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -1,12 +1,18 @@ +import urllib.parse + from django.db import transaction +from django import urls +from django.conf import settings from rest_framework import serializers from taggit.models import Tag from versatileimagefield.serializers import VersatileImageFieldSerializer from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import routes +from funkwhale_api.federation import utils as federation_utils from . import filters, models, tasks @@ -380,3 +386,98 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): def get_type(self, obj): return "Audio" + + +class OembedSerializer(serializers.Serializer): + format = serializers.ChoiceField(choices=["json"]) + url = serializers.URLField() + maxheight = serializers.IntegerField(required=False) + maxwidth = serializers.IntegerField(required=False) + + def validate(self, validated_data): + try: + match = common_utils.spa_resolve( + urllib.parse.urlparse(validated_data["url"]).path + ) + except urls.exceptions.Resolver404: + raise serializers.ValidationError( + "Invalid URL {}".format(validated_data["url"]) + ) + data = { + "version": 1.0, + "type": "rich", + "provider_name": "{} - {}".format( + preferences.get("instance__name"), settings.APP_NAME + ), + "provider_url": settings.FUNKWHALE_URL, + "height": validated_data.get("maxheight") or 400, + "width": validated_data.get("maxwidth") or 600, + } + embed_id = None + embed_type = None + if match.url_name == "library_track": + qs = models.Track.objects.select_related("artist", "album__artist").filter( + pk=int(match.kwargs["pk"]) + ) + try: + track = qs.get() + except models.Track.DoesNotExist: + raise serializers.ValidationError( + "No track matching id {}".format(match.kwargs["pk"]) + ) + embed_type = "track" + embed_id = track.pk + data["title"] = "{} by {}".format(track.title, track.artist.name) + if track.album.cover: + data["thumbnail_url"] = federation_utils.full_url( + track.album.cover.crop["400x400"].url + ) + data["description"] = track.full_name + data["author_name"] = track.artist.name + data["height"] = 150 + data["author_url"] = federation_utils.full_url( + common_utils.spa_reverse( + "library_artist", kwargs={"pk": track.artist.pk} + ) + ) + elif match.url_name == "library_album": + qs = models.Album.objects.select_related("artist").filter( + pk=int(match.kwargs["pk"]) + ) + try: + album = qs.get() + except models.Album.DoesNotExist: + raise serializers.ValidationError( + "No album matching id {}".format(match.kwargs["pk"]) + ) + embed_type = "album" + embed_id = album.pk + if album.cover: + data["thumbnail_url"] = federation_utils.full_url( + album.cover.crop["400x400"].url + ) + data["title"] = "{} by {}".format(album.title, album.artist.name) + data["description"] = "{} by {}".format(album.title, album.artist.name) + data["author_name"] = album.artist.name + data["height"] = 400 + data["author_url"] = federation_utils.full_url( + common_utils.spa_reverse( + "library_artist", kwargs={"pk": album.artist.pk} + ) + ) + else: + raise serializers.ValidationError( + "Unsupported url: {}".format(validated_data["url"]) + ) + data[ + "html" + ] = '<iframe width="{}" height="{}" scrolling="no" frameborder="no" src="{}"></iframe>'.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 0000000000000000000000000000000000000000..c1476e8a93c70d94134e02c93a9824677885f2d8 --- /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 19a00884bbd32de166756fd9aa7760aa1baa8bdb..1fcd782fbb8602f882ec67fc3ffc2d3334b17df8 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 0000000000000000000000000000000000000000..2ed875a53100fb216d09ade2af6fceea3bee4ca1 --- /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="<html><head></head></html>" + ) + 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 = [ + "<html><head>", + '<meta content="custom title" property="og:title" />', + '<meta content="custom description" property="og:description" />', + '<meta content="default site name" property="og:site_name" />', + "</head></html>", + ] + 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 d880c8d6faa96c7f007f81721f607fe439040dbd..99317303c86aa8f9980e7dda2196fcf3e48906f8 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="<head></head>" + ) + + +@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 0000000000000000000000000000000000000000..a60da50dffb3e9bb7f023e8212a1b3c045dd92bc --- /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 263b7878472dcd9f53ed94737e3f05ecd79ce7d2..d954787b0e529acd1019af55b00b30abc5fd7e66 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": '<iframe width="600" height="150" scrolling="no" frameborder="no" src="{}"></iframe>'.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": '<iframe width="600" height="400" scrolling="no" frameborder="no" src="{}"></iframe>'.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 d73a1c4b6ebd176aa0ef761affb80ba1c667a2d8..1e2ab0014b74d54e2e7e55db1255aee3c66e6860 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 1eb011d4efc1ff11162863b1e69d21c847422d3a..f6bf610694946ccfa5193afe57acb723cc3452e2 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/ {