feat: Create an oEmbed endpoint for maps

Fix #162
This commit is contained in:
David Larlet 2024-01-11 14:50:02 -05:00
parent b75e3bedd7
commit df76ffd80e
No known key found for this signature in database
GPG key ID: 3E2953A359E7E7BD
5 changed files with 130 additions and 8 deletions

View file

@ -12,6 +12,9 @@
{% endcompress %} {% endcompress %}
{% umap_js locale=locale %} {% umap_js locale=locale %}
{% if object.share_status != object.PUBLIC %}<meta name="robots" content="noindex">{% endif %} {% if object.share_status != object.PUBLIC %}<meta name="robots" content="noindex">{% endif %}
<link rel="alternate" type="application/json+oembed"
href="{{ oembed_absolute_uri }}?url={{ absolute_uri|urlencode }}&format=json"
title="{{ map.name }} oEmbed URL" />
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
{% block map_init %} {% block map_init %}

View file

@ -275,7 +275,7 @@ def test_owner_cannot_access_map_with_share_status_blocked(client, map):
assert response.status_code == 403 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)) url = reverse("map", args=(map.slug, map.pk))
map.share_status = map.PRIVATE map.share_status = map.PRIVATE
map.save() map.save()
@ -346,14 +346,14 @@ def test_anonymous_create(cookieclient, post_data):
@pytest.mark.usefixtures("allow_anonymous") @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}) url = reverse("map_update", kwargs={"map_id": anonymap.pk})
response = client.post(url, post_data) response = client.post(url, post_data)
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.usefixtures("allow_anonymous") @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}) url = reverse("map_update", kwargs={"map_id": anonymap.pk})
# POST only mendatory fields # POST only mendatory fields
name = "new map name" name = "new map name"
@ -420,7 +420,7 @@ def test_bad_anonymous_edit_url_should_return_403(cookieclient, anonymap):
@pytest.mark.usefixtures("allow_anonymous") @pytest.mark.usefixtures("allow_anonymous")
def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed( def test_clone_anonymous_map_should_not_be_possible_if_user_is_not_allowed(
client, anonymap, user client, anonymap, user
): # noqa ):
assert Map.objects.count() == 1 assert Map.objects.count() == 1
url = reverse("map_clone", kwargs={"map_id": anonymap.pk}) url = reverse("map_clone", kwargs={"map_id": anonymap.pk})
anonymap.edit_status = anonymap.OWNER 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") @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 assert Map.objects.count() == 1
url = reverse("map_clone", kwargs={"map_id": anonymap.pk}) url = reverse("map_clone", kwargs={"map_id": anonymap.pk})
anonymap.edit_status = anonymap.ANONYMOUS anonymap.edit_status = anonymap.ANONYMOUS
@ -675,3 +675,63 @@ def test_download_my_map(client, map, datalayer):
# Test response is a json # Test response is a json
j = json.loads(response.content.decode()) j = json.loads(response.content.decode())
assert j["type"] == "umap" 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"] == (
'<iframe width="100%" height="300px" frameborder="0" allowfullscreen '
f'allow="geolocation" src="//testserver/en/map/test-map_{map.id}"></iframe>'
f'<p><a href="//testserver/en/map/test-map_{map.id}">See full screen</a></p>'
)
def test_oembed_link(client, map, datalayer):
response = client.get(map.get_absolute_url())
assert response.status_code == 200
assert (
'<link rel="alternate" type="application/json+oembed"'
in response.content.decode()
)
assert (
'href="http://testserver/map/oembed/'
f'?url=http%3A//testserver/en/map/test-map_{map.id}&format=json"'
) in response.content.decode()
assert 'title="test map oEmbed URL" />' in response.content.decode()

View file

@ -1,6 +1,6 @@
import json import json
import socket import socket
from datetime import date, datetime, timedelta from datetime import datetime, timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings

View file

@ -41,6 +41,7 @@ urlpatterns = [
), ),
re_path(r"^i18n/", include("django.conf.urls.i18n")), re_path(r"^i18n/", include("django.conf.urls.i18n")),
re_path(r"^agnocomplete/", include("agnocomplete.urls")), re_path(r"^agnocomplete/", include("agnocomplete.urls")),
re_path(r"^map/oembed/", views.MapOEmbed.as_view(), name="map_oembed"),
re_path( re_path(
r"^map/(?P<map_id>\d+)/download/", r"^map/(?P<map_id>\d+)/download/",
can_view_map(views.MapDownload.as_view()), can_view_map(views.MapDownload.as_view()),

View file

@ -18,21 +18,23 @@ from django.contrib.auth import logout as do_logout
from django.contrib.gis.measure import D from django.contrib.gis.measure import D
from django.contrib.postgres.search import SearchQuery, SearchVector from django.contrib.postgres.search import SearchQuery, SearchVector
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail from django.core.mail import send_mail
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.core.signing import BadSignature, Signer from django.core.signing import BadSignature, Signer
from django.core.validators import URLValidator, ValidationError from django.core.validators import URLValidator, ValidationError
from django.db.models import Q
from django.http import ( from django.http import (
Http404,
HttpResponse, HttpResponse,
HttpResponseBadRequest, HttpResponseBadRequest,
HttpResponseForbidden, HttpResponseForbidden,
HttpResponsePermanentRedirect, HttpResponsePermanentRedirect,
HttpResponseRedirect, HttpResponseRedirect,
HttpResponseServerError,
) )
from django.middleware.gzip import re_accepts_gzip from django.middleware.gzip import re_accepts_gzip
from django.shortcuts import get_object_or_404 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.encoding import smart_bytes
from django.utils.http import http_date from django.utils.http import http_date
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
@ -526,6 +528,16 @@ class PermissionsMixin:
class MapView(MapDetailMixin, PermissionsMixin, DetailView): 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): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
canonical = self.get_canonical_url() canonical = self.get_canonical_url()
@ -607,6 +619,52 @@ class MapDownload(DetailView):
return response 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'<iframe width="100%" height="{height}px" '
f'frameborder="0" allowfullscreen allow="geolocation" '
f'src="//{netloc}{map_url}"></iframe>'
f'<p><a href="//{netloc}{map_url}">{label}</a></p>'
)
data["html"] = html
return simple_json_response(**data)
class MapViewGeoJSON(MapView): class MapViewGeoJSON(MapView):
def get_canonical_url(self): def get_canonical_url(self):
return reverse("map_geojson", args=(self.object.pk,)) return reverse("map_geojson", args=(self.object.pk,))