Media was unreachable from either portal, which is why the certificate button on the buyer compliance page returned page-not-found and no document row had anything to click. The cause was structural: the portal and ops hosts proxy location / to the app with a path prefix, so a stored /media/... URL arrives as /portal/media/... and matches no route. Add a download view per portal behind the same role gate as the page that links to it — buyer-visible documents and published certificates for buyers, anything the ops list shows for operations — resolving the stored path safely under MEDIA_ROOT so a traversal or an absolute path is refused. Point the seven templates that list files at the new routes. Also add a portal-wide render sweep: nothing in the suite was fetching the ops or portal pages, so a view could lose its template_name and 500 in front of a user with every test still green.
60 lines
2.4 KiB
Python
60 lines
2.4 KiB
Python
"""Permission-checked document downloads.
|
|
|
|
Neither portal can serve a stored document today, and the reason is structural
|
|
rather than a missing link. The portals sit behind nginx servers that proxy
|
|
``location /`` to the Django app **with a path prefix** (``/portal/`` and
|
|
``/ops/``), so a stored ``/media/...`` URL arrives at Django as
|
|
``/portal/media/...`` — a route that does not exist. Django's own DEBUG media
|
|
route is registered at ``/media/``, not under either prefix, so even a file that
|
|
is present on disk comes back as a page-not-found.
|
|
|
|
Serving media directly from the web server would not be the answer either:
|
|
every document row carries a visibility setting (buyer / ops / admin) and every
|
|
certificate carries a status, so the file behind a row is not public. The
|
|
download has to pass through the same permission gate as the page that links to
|
|
it.
|
|
|
|
So: these helpers resolve a stored ``file_url`` to a real path under
|
|
``MEDIA_ROOT`` — refusing anything that escapes it — and stream the bytes back
|
|
to a caller that has already passed a portal permission gate.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from django.http import FileResponse, Http404
|
|
|
|
|
|
def resolve_media_path(file_url):
|
|
"""Absolute path for a stored ``/media/...`` file_url, or None if unacceptable.
|
|
|
|
None means "do not serve this": an empty value, a path outside MEDIA_URL, or
|
|
a path that resolves outside MEDIA_ROOT (``../`` and symlink escapes).
|
|
"""
|
|
if not file_url:
|
|
return None
|
|
|
|
media_url = settings.MEDIA_URL.rstrip("/") # "/media"
|
|
rel = file_url.strip()
|
|
if rel.startswith(media_url + "/"):
|
|
rel = rel[len(media_url) + 1:]
|
|
elif rel.startswith("/"):
|
|
# An absolute filesystem path, rather than a path under MEDIA_URL.
|
|
return None
|
|
|
|
base = Path(settings.MEDIA_ROOT).resolve()
|
|
target = (base / rel).resolve()
|
|
if target != base and base not in target.parents:
|
|
return None
|
|
return target
|
|
|
|
|
|
def file_response(file_url, download_name=None):
|
|
"""Stream a stored file back as an inline response.
|
|
|
|
Raises 404 when the value cannot be resolved or the file is not on disk, so a
|
|
broken row reads as "not found" rather than as a server error.
|
|
"""
|
|
path = resolve_media_path(file_url)
|
|
if path is None or not path.is_file():
|
|
raise Http404("Document file not found")
|
|
return FileResponse(open(path, "rb"), filename=download_name or path.name)
|