diff --git a/apps/reports/views.py b/apps/reports/views.py index da5ccaaea..9b73184e8 100644 --- a/apps/reports/views.py +++ b/apps/reports/views.py @@ -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}) \ No newline at end of file + return JsonResponse({"message": _('Email sent successfully to ') + email})