From 88f787afe246944ed3414674f89358a4b2d235be Mon Sep 17 00:00:00 2001 From: Thomas NOEL Date: Tue, 14 Mar 2023 16:23:17 +0100 Subject: [PATCH] add /media generic view (#75378) --- passerelle/urls.py | 11 ++------- passerelle/views.py | 18 ++++++++++++++ tests/test_media.py | 60 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 tests/test_media.py diff --git a/passerelle/urls.py b/passerelle/urls.py index b99db343..f30c84d3 100644 --- a/passerelle/urls.py +++ b/passerelle/urls.py @@ -1,9 +1,7 @@ from django.conf import settings from django.contrib import admin -from django.contrib.auth.decorators import login_required from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import include, path, re_path -from django.views.static import serve as static_serve from .api.urls import urlpatterns as api_urls from .base.urls import access_urlpatterns, import_export_urlpatterns @@ -22,6 +20,7 @@ from .views import ( HomePageView, ManageAddView, ManageView, + MediaView, login, logout, menu_json, @@ -34,13 +33,7 @@ urlpatterns = [ path('manage/', manager_required(ManageView.as_view()), name='manage-home'), re_path(r'^manage/menu.json$', manager_required(menu_json), name='menu-json'), path('manage/add', manager_required(ManageAddView.as_view()), name='add-connector'), - re_path( - r'^media/(?P.*)$', - login_required(static_serve), - { - 'document_root': settings.MEDIA_ROOT, - }, - ), + re_path(r'^media/(?P.*)$', manager_required(MediaView.as_view()), name='media'), re_path(r'^admin/', admin.site.urls), re_path(r'^manage/access/', decorated_includes(manager_required, include(access_urlpatterns))), re_path(r'^manage/', decorated_includes(manager_required, include(import_export_urlpatterns))), diff --git a/passerelle/views.py b/passerelle/views.py index 978ce48d..574344cc 100644 --- a/passerelle/views.py +++ b/passerelle/views.py @@ -19,6 +19,7 @@ import hashlib import inspect import json import logging +import os import uuid from urllib.parse import quote @@ -29,6 +30,7 @@ from django.contrib.auth import logout as auth_logout from django.contrib.auth import views as auth_views from django.core.cache import cache from django.core.exceptions import PermissionDenied +from django.core.files.storage import DefaultStorage from django.db import transaction from django.db.models import Q from django.http import Http404, HttpResponse, HttpResponseRedirect @@ -50,6 +52,7 @@ from django.views.generic import ( View, ) from django.views.generic.detail import SingleObjectMixin +from django.views.static import serve from jsonschema import ValidationError, validate, validators from passerelle.base.models import BaseResource, ResourceLog @@ -629,3 +632,18 @@ class GenericExportConnectorView(GenericConnectorMixin, DetailView): ) json.dump({'resources': [self.get_object().export_json()]}, response, indent=2) return response + + +class MediaView(View): + def get(self, request, path, *args, **kwargs): + document_root = DefaultStorage().location + filename = DefaultStorage().path(path) + filename = os.path.realpath(filename) + if ( + not os.path.isabs(filename) + or not filename.startswith(document_root) + or not os.path.exists(filename) + or not os.path.isfile(filename) + ): + raise Http404() + return serve(request, path, document_root=document_root, show_indexes=False) diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100644 index 00000000..15c954cf --- /dev/null +++ b/tests/test_media.py @@ -0,0 +1,60 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2023 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import pytest +from django.core.files.base import ContentFile + +from passerelle.apps.pdf.models import Resource +from tests.test_manager import login +from tests.utils import setup_access_rights + + +@pytest.fixture +def pdf(db): + return setup_access_rights(Resource.objects.create(slug='test', title='test', description='test')) + + +@pytest.fixture +def cerfa_content(): + with open('tests/data/cerfa_10072-02.pdf', 'rb') as fd: + return fd.read() + + +def test_media(app, admin_user, simple_user, pdf, cerfa_content): + pdf.fill_form_file.save('form.pdf', ContentFile(cerfa_content)) + + # refuse anonymous or simple user + resp = app.get('/media/pdf/test/form.pdf', status=302) + assert resp.location == '/login/?next=/media/pdf/test/form.pdf' + + app = login(app, username='user', password='user') + resp = app.get('/media/pdf/test/form.pdf', status=403) + + # allow manager access + app = login(app, username='admin', password='admin') + resp = app.get('/media/pdf/test/form.pdf') + assert resp.content.startswith(b'%PDF') + assert resp.headers['content-type'] == 'application/pdf' + + # bad requests: 404 or 400 + resp = app.get('/media/pdf/plop/there-is-not-file-here.pdf', status=404) + resp = app.get('/media/pdf/bad-slug/form.pdf', status=404) + resp = app.get('/media/pdf/', status=404) + resp = app.get('/media/pdf', status=404) + resp = app.get('/media/', status=404) + resp = app.get('/media/../etc/passwd', status=400) + resp = app.get('/media/../../../../../../../../etc/passwd', status=400)