From df76ffd80e4553869b08b67d091130dd96b25029 Mon Sep 17 00:00:00 2001 From: David Larlet Date: Thu, 11 Jan 2024 14:50:02 -0500 Subject: [PATCH] feat: Create an oEmbed endpoint for maps Fix #162 --- umap/templates/umap/map_detail.html | 3 ++ umap/tests/test_map_views.py | 70 ++++++++++++++++++++++++++--- umap/tests/test_views.py | 2 +- umap/urls.py | 1 + umap/views.py | 62 ++++++++++++++++++++++++- 5 files changed, 130 insertions(+), 8 deletions(-) diff --git a/umap/templates/umap/map_detail.html b/umap/templates/umap/map_detail.html index 25f927e7..8efc7c96 100644 --- a/umap/templates/umap/map_detail.html +++ b/umap/templates/umap/map_detail.html @@ -12,6 +12,9 @@ {% endcompress %} {% umap_js locale=locale %} {% if object.share_status != object.PUBLIC %}{% endif %} + {% endblock extra_head %} {% block content %} {% block map_init %} diff --git a/umap/tests/test_map_views.py b/umap/tests/test_map_views.py index 6ef798e2..b8595900 100644 --- a/umap/tests/test_map_views.py +++ b/umap/tests/test_map_views.py @@ -275,7 +275,7 @@ def test_owner_cannot_access_map_with_share_status_blocked(client, map): assert response.status_code == 403 -def test_non_editor_cannot_access_map_if_share_status_private(client, map, user): # noqa +def test_non_editor_cannot_access_map_if_share_status_private(client, map, user): url = reverse("map", args=(map.slug, map.pk)) map.share_status = map.PRIVATE map.save() @@ -346,14 +346,14 @@ def test_anonymous_create(cookieclient, post_data): @pytest.mark.usefixtures("allow_anonymous") -def test_anonymous_update_without_cookie_fails(client, anonymap, post_data): # noqa +def test_anonymous_update_without_cookie_fails(client, anonymap, post_data): url = reverse("map_update", kwargs={"map_id": anonymap.pk}) response = client.post(url, post_data) assert response.status_code == 403 @pytest.mark.usefixtures("allow_anonymous") -def test_anonymous_update_with_cookie_should_work(cookieclient, anonymap, post_data): # noqa +def test_anonymous_update_with_cookie_should_work(cookieclient, anonymap, post_data): url = reverse("map_update", kwargs={"map_id": anonymap.pk}) # POST only mendatory fields name = "new map name" @@ -420,7 +420,7 @@ def test_bad_anonymous_edit_url_should_return_403(cookieclient, anonymap): @pytest.mark.usefixtures("allow_anonymous") def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed( client, anonymap, user -): # noqa +): assert Map.objects.count() == 1 url = reverse("map_clone", kwargs={"map_id": anonymap.pk}) anonymap.edit_status = anonymap.OWNER @@ -434,7 +434,7 @@ def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed( @pytest.mark.usefixtures("allow_anonymous") -def test_clone_map_should_be_possible_if_edit_status_is_anonymous(client, anonymap): # noqa +def test_clone_map_should_be_possible_if_edit_status_is_anonymous(client, anonymap): assert Map.objects.count() == 1 url = reverse("map_clone", kwargs={"map_id": anonymap.pk}) anonymap.edit_status = anonymap.ANONYMOUS @@ -675,3 +675,63 @@ def test_download_my_map(client, map, datalayer): # Test response is a json j = json.loads(response.content.decode()) assert j["type"] == "umap" + + +@pytest.mark.parametrize("share_status", [Map.PRIVATE, Map.BLOCKED, Map.OPEN]) +def test_oembed_shared_status_map(client, map, datalayer, share_status): + map.share_status = share_status + map.save() + url = f"{reverse('map_oembed')}?url=http://testserver{map.get_absolute_url()}" + response = client.get(url) + assert response.status_code == 403 + + +def test_oembed_no_url_map(client, map, datalayer): + url = reverse("map_oembed") + response = client.get(url) + assert response.status_code == 404 + + +def test_oembed_wrong_format_map(client, map, datalayer): + url = ( + f"{reverse('map_oembed')}" + f"?url=http://testserver{map.get_absolute_url()}&format=xml" + ) + response = client.get(url) + assert response.status_code == 501 + + +def test_oembed_wrong_domain_map(client, map, datalayer): + url = f"{reverse('map_oembed')}?url=http://BADserver{map.get_absolute_url()}" + response = client.get(url) + assert response.status_code == 404 + + +def test_oembed_map(client, map, datalayer): + url = f"{reverse('map_oembed')}?url=http://testserver{map.get_absolute_url()}" + response = client.get(url) + assert response.status_code == 200 + j = json.loads(response.content.decode()) + assert j["type"] == "rich" + assert j["version"] == "1.0" + assert j["width"] == 800 + assert j["height"] == 300 + assert j["html"] == ( + '' + f'

See full screen

' + ) + + +def test_oembed_link(client, map, datalayer): + response = client.get(map.get_absolute_url()) + assert response.status_code == 200 + assert ( + '' in response.content.decode() diff --git a/umap/tests/test_views.py b/umap/tests/test_views.py index 1c865357..5686500c 100644 --- a/umap/tests/test_views.py +++ b/umap/tests/test_views.py @@ -1,6 +1,6 @@ import json import socket -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta import pytest from django.conf import settings diff --git a/umap/urls.py b/umap/urls.py index f2905025..53893748 100644 --- a/umap/urls.py +++ b/umap/urls.py @@ -41,6 +41,7 @@ urlpatterns = [ ), re_path(r"^i18n/", include("django.conf.urls.i18n")), re_path(r"^agnocomplete/", include("agnocomplete.urls")), + re_path(r"^map/oembed/", views.MapOEmbed.as_view(), name="map_oembed"), re_path( r"^map/(?P\d+)/download/", can_view_map(views.MapDownload.as_view()), diff --git a/umap/views.py b/umap/views.py index 1032bdf0..d661dcfe 100644 --- a/umap/views.py +++ b/umap/views.py @@ -18,21 +18,23 @@ from django.contrib.auth import logout as do_logout from django.contrib.gis.measure import D from django.contrib.postgres.search import SearchQuery, SearchVector from django.contrib.staticfiles.storage import staticfiles_storage +from django.core.exceptions import PermissionDenied from django.core.mail import send_mail from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.signing import BadSignature, Signer from django.core.validators import URLValidator, ValidationError -from django.db.models import Q from django.http import ( + Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponsePermanentRedirect, HttpResponseRedirect, + HttpResponseServerError, ) from django.middleware.gzip import re_accepts_gzip from django.shortcuts import get_object_or_404 -from django.urls import reverse, reverse_lazy +from django.urls import resolve, reverse, reverse_lazy from django.utils.encoding import smart_bytes from django.utils.http import http_date from django.utils.timezone import make_aware @@ -526,6 +528,16 @@ class PermissionsMixin: class MapView(MapDetailMixin, PermissionsMixin, DetailView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["oembed_absolute_uri"] = self.request.build_absolute_uri( + reverse("map_oembed") + ) + context["absolute_uri"] = self.request.build_absolute_uri( + self.object.get_absolute_url() + ) + return context + def get(self, request, *args, **kwargs): self.object = self.get_object() canonical = self.get_canonical_url() @@ -607,6 +619,52 @@ class MapDownload(DetailView): return response +class MapOEmbed(View): + def get(self, request, *args, **kwargs): + data = {"type": "rich", "version": "1.0"} + format_ = request.GET.get("format", "json") + if format_ != "json": + response = HttpResponseServerError("Only `json` format is implemented.") + response.status_code = 501 + return response + + url = request.GET.get("url") + if not url: + raise Http404("Missing `url` parameter.") + + parsed_url = urlparse(url) + netloc = parsed_url.netloc + allowed_hosts = settings.ALLOWED_HOSTS + if parsed_url.hostname not in allowed_hosts and allowed_hosts != ["*"]: + raise Http404("Host not allowed.") + + url_path = parsed_url.path + view, args, kwargs = resolve(url_path) + if "slug" not in kwargs or "map_id" not in kwargs: + raise Http404("Invalid URL path.") + + map_ = Map.objects.get(id=kwargs["map_id"], slug=kwargs["slug"]) + + if map_.share_status != Map.PUBLIC: + raise PermissionDenied("This map is not public.") + + map_url = map_.get_absolute_url() + label = _("See full screen") + height = 300 + data["height"] = height + width = 800 + data["width"] = width + # TODISCUSS: do we keep width=100% by default for the iframe? + html = ( + f'' + f'

{label}

' + ) + data["html"] = html + return simple_json_response(**data) + + class MapViewGeoJSON(MapView): def get_canonical_url(self): return reverse("map_geojson", args=(self.object.pk,))