fix(reports): secure PDF export URL and authentication

This commit is contained in:
Crane.z
2026-07-03 14:21:16 +08:00
committed by 老广
parent 24520f1e8b
commit 128aeed5f2

View File

@@ -5,8 +5,11 @@ from io import BytesIO
from urllib.parse import parse_qsl, urlencode, urlparse, urlsplit, urlunsplit
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.mail import EmailMultiAlternatives
from django.http import FileResponse, HttpResponseBadRequest, JsonResponse
from django.http import (
FileResponse, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
@@ -58,30 +61,51 @@ charts_map = {
}
def export_chart_to_pdf(chart_name, sessionid, request=None):
def get_trusted_site_url():
# Playwright navigation and cookie scope must never depend on request Host.
site_url = getattr(settings, 'SITE_URL', '')
if not isinstance(site_url, str):
raise ValueError('Invalid SITE_URL')
site_url = site_url.rstrip('/')
parsed_site = urlparse(site_url)
if (
not site_url or parsed_site.scheme not in ('http', 'https') or
not parsed_site.hostname or parsed_site.username or
parsed_site.password or parsed_site.query or parsed_site.fragment
):
raise ValueError('Invalid SITE_URL')
return site_url, parsed_site
def build_report_url(chart_path):
path = urllib.parse.unquote(chart_path)
if not path.startswith('/ui/#/reports/'):
raise ValueError('Invalid report path')
site_url, _ = get_trusted_site_url()
return site_url + path
def export_chart_to_pdf(chart_name, sessionid, request):
chart_info = charts_map.get(chart_name)
if not chart_info:
return None, None
if request:
url = request.build_absolute_uri(urllib.parse.unquote(chart_info['path']))
else:
url = urllib.parse.unquote(chart_info['path'])
url = build_report_url(chart_info['path'])
_, parsed_site = get_trusted_site_url()
if settings.DEBUG_DEV:
url = url.replace(":8080", ":9528")
if request:
oid = request.COOKIES.get("X-JMS-ORG")
request_data = request.POST if request.method == 'POST' else request.GET
query = dict(parse_qsl(urlsplit(url).query, keep_blank_values=True))
for key, value in request_data.items():
if key in {'chart', 'csrfmiddlewaretoken'}:
continue
query[key] = value
query.setdefault('days', 7)
query['oid'] = oid
parts = list(urlsplit(url))
parts[3] = urlencode(query, doseq=True)
url = urlunsplit(parts)
oid = request.COOKIES.get("X-JMS-ORG")
request_data = request.POST if request.method == 'POST' else request.GET
query = dict(parse_qsl(urlsplit(url).query, keep_blank_values=True))
for key, value in request_data.items():
if key in {'chart', 'csrfmiddlewaretoken'}:
continue
query[key] = value
query.setdefault('days', 7)
query['oid'] = oid
parts = list(urlsplit(url))
parts[3] = urlencode(query, doseq=True)
url = urlunsplit(parts)
with sync_playwright() as p:
lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
@@ -92,12 +116,12 @@ def export_chart_to_pdf(chart_name, sessionid, request=None):
ignore_https_errors=True
)
# 设置 sessionid cookie
parsed_url = urlparse(url)
context.add_cookies([
{
'name': settings.SESSION_COOKIE_NAME,
'value': sessionid,
'domain': parsed_url.hostname,
# hostname is derived from SITE_URL and never includes a port.
'domain': parsed_site.hostname,
'path': '/',
'httpOnly': True,
'secure': False, # 如有 https 可改 True
@@ -122,7 +146,7 @@ def export_chart_to_pdf(chart_name, sessionid, request=None):
@method_decorator(csrf_exempt, name='dispatch')
class ExportPdfView(View):
class ExportPdfView(LoginRequiredMixin, View):
def get(self, request):
chart_name = request.GET.get('chart')
return self._handle_export(request, chart_name)
@@ -132,13 +156,20 @@ class ExportPdfView(View):
return self._handle_export(request, chart_name)
def _handle_export(self, request, chart_name):
if not request.user.is_authenticated:
return HttpResponse(status=401)
if not chart_name:
return HttpResponseBadRequest('Missing chart parameter')
sessionid = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
if not sessionid:
return HttpResponseBadRequest('No sessionid found in cookies')
pdf_bytes, title = export_chart_to_pdf(chart_name, sessionid, request=request)
try:
pdf_bytes, title = export_chart_to_pdf(
chart_name, sessionid, request=request
)
except ValueError:
return HttpResponseBadRequest('Invalid report configuration')
if not pdf_bytes:
return HttpResponseBadRequest('Failed to generate PDF')
filename = f"{title}-{timezone.now().strftime('%Y%m%d%H%M%S')}.pdf"
@@ -147,9 +178,11 @@ class ExportPdfView(View):
return response
class SendMailView(View):
class SendMailView(LoginRequiredMixin, View):
def post(self, request):
if not request.user.is_authenticated:
return HttpResponse(status=401)
chart_name = request.GET.get('chart') or request.POST.get('chart')
if not chart_name:
return HttpResponseBadRequest('Missing chart parameter')
@@ -161,7 +194,12 @@ class SendMailView(View):
return HttpResponseBadRequest('No sessionid found in cookies')
# 1. 生成 PDF
pdf_bytes, title = export_chart_to_pdf(chart_name, sessionid, request=request)
try:
pdf_bytes, title = export_chart_to_pdf(
chart_name, sessionid, request=request
)
except ValueError:
return HttpResponseBadRequest('Invalid report configuration')
if not pdf_bytes:
return HttpResponseBadRequest('Failed to generate PDF')
@@ -188,4 +226,4 @@ class SendMailView(View):
msg.send()
except Exception as e:
return JsonResponse({"error": _('Failed to send email: ') + str(e)})
return JsonResponse({"message": _('Email sent successfully to ') + email})
return JsonResponse({"message": _('Email sent successfully to ') + email})