ixg_platform/apps/documents/downloads.py
ixgadmin cdefbd010c Serve document and certificate files through a permission-checked download
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.
2026-09-17 12:44:52 +00:00

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)