From 5682d3ed560b336253e04e1d6cbfbae44bccc355 Mon Sep 17 00:00:00 2001 From: Richard Schreiber Date: Tue, 14 Apr 2026 09:12:09 +0200 Subject: [PATCH] Do not force PDFs to be downloaded (Z#23225892) (#5994) * Display invoice and tickets inline in browser (Z#23225892) * Use FileResponse filename for AnswerDownload * Use inline for PDF-view in pretix-control editor * use as_attachment for API FileResponses * do not ignore csp even for disposition=inline * use as_attachment for file responses in control * remove unused code * improve code style * Invoice preview inline * do not force download on tickets in backend * do not force download on AnswerDownload * imrpove code style * improve code style * fix missing int str conversion * Apply suggestions from code review Co-authored-by: luelista --------- Co-authored-by: luelista --- src/pretix/api/views/order.py | 71 +++++++++++++-------- src/pretix/control/views/event.py | 7 +- src/pretix/control/views/global_settings.py | 1 - src/pretix/control/views/orders.py | 32 ++++++---- src/pretix/control/views/pdf.py | 10 +-- src/pretix/presale/views/cart.py | 10 ++- src/pretix/presale/views/order.py | 41 ++++++------ 7 files changed, 90 insertions(+), 82 deletions(-) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 6d5184db81..4f2b88da70 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -381,12 +381,15 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') return resp else: - resp = FileResponse(ct.file.file, content_type=ct.type) - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( - self.request.event.slug.upper(), order.code, - provider.identifier, ct.extension + return FileResponse( + ct.file.file, + filename='{}-{}-{}{}'.format( + self.request.event.slug.upper(), order.code, + provider.identifier, ct.extension + ), + as_attachment=True, + content_type=ct.type ) - return resp @action(detail=True, methods=['POST']) def mark_paid(self, request, **kwargs): @@ -1303,14 +1306,17 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet raise NotFound() ftype, ignored = mimetypes.guess_type(answer.file.name) - resp = FileResponse(answer.file, content_type=ftype or 'application/binary') - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format( - self.request.event.slug.upper(), - pos.order.code, - pos.positionid, - os.path.basename(answer.file.name).split('.', 1)[1] + return FileResponse( + answer.file, + filename='{}-{}-{}-{}"'.format( + self.request.event.slug.upper(), + pos.order.code, + pos.positionid, + os.path.basename(answer.file.name).split('.', 1)[1] + ), + as_attachment=True, + content_type=ftype or 'application/binary' ) - return resp @action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"]) def printlog(self, request, **kwargs): @@ -1365,15 +1371,18 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet if hasattr(image_file, 'seek'): image_file.seek(0) - resp = FileResponse(image_file, content_type=ftype or 'application/binary') - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format( - self.request.event.slug.upper(), - pos.order.code, - pos.positionid, - key, - extension, + return FileResponse( + image_file, + filename='{}-{}-{}-{}.{}'.format( + self.request.event.slug.upper(), + pos.order.code, + pos.positionid, + key, + extension, + ), + as_attachment=True, + content_type=ftype or 'application/binary' ) - return resp @action(detail=True, url_name='download', url_path='download/(?P[^/]+)') def download(self, request, output, **kwargs): @@ -1399,12 +1408,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') return resp else: - resp = FileResponse(ct.file.file, content_type=ct.type) - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( - self.request.event.slug.upper(), pos.order.code, pos.positionid, - provider.identifier, ct.extension + return FileResponse( + ct.file.file, + filename='{}-{}-{}-{}{}'.format( + self.request.event.slug.upper(), pos.order.code, pos.positionid, + provider.identifier, ct.extension + ), + as_attachment=True, + content_type=ct.type ) - return resp @action(detail=True, methods=['POST']) def regenerate_secrets(self, request, **kwargs): @@ -1986,9 +1998,12 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): if not invoice.file: raise RetryException() - resp = FileResponse(invoice.file.file, content_type='application/pdf') - resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number) - return resp + return FileResponse( + invoice.file.file, + filename='{}.pdf"'.format(invoice.number), + as_attachment=True, + content_type='application/pdf' + ) @action(detail=True, methods=['POST']) def transmit(self, request, **kwargs): diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 92eb58c7d9..7a25d97bc8 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -763,12 +763,7 @@ class InvoicePreview(EventPermissionRequiredMixin, View): def get(self, request, *args, **kwargs): fname, ftype, fcontent = build_preview_invoice_pdf(request.event) resp = HttpResponse(fcontent, content_type=ftype) - if settings.DEBUG: - # attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging - resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname) - resp._csp_ignore = True - else: - resp['Content-Disposition'] = 'attachment; filename="{}"'.format(fname) + resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname) return resp diff --git a/src/pretix/control/views/global_settings.py b/src/pretix/control/views/global_settings.py index 815019bc0b..d5c6b0a55b 100644 --- a/src/pretix/control/views/global_settings.py +++ b/src/pretix/control/views/global_settings.py @@ -300,5 +300,4 @@ class SysReportView(AdministratorPermissionRequiredMixin, TemplateView): resp = HttpResponse(data) resp['Content-Type'] = mime resp['Content-Disposition'] = 'inline; filename="{}"'.format(name) - resp._csp_ignore = True return resp diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 5d60911009..d0bb06cc45 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -710,22 +710,26 @@ class OrderDownload(AsyncAction, OrderView): resp = HttpResponseRedirect(value.file.file.read()) return resp else: - resp = FileResponse(value.file.file, content_type=value.type) - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( - self.request.event.slug.upper(), self.order.code, self.order_position.positionid, - self.output.identifier, value.extension + return FileResponse( + value.file.file, + filename='{}-{}-{}-{}{}'.format( + self.request.event.slug.upper(), self.order.code, self.order_position.positionid, + self.output.identifier, value.extension + ), + content_type=value.type ) - return resp elif isinstance(value, CachedCombinedTicket): if value.type == 'text/uri-list': resp = HttpResponseRedirect(value.file.file.read()) return resp else: - resp = FileResponse(value.file.file, content_type=value.type) - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( - self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension + return FileResponse( + value.file.file, + filename='{}-{}-{}{}'.format( + self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension + ), + content_type=value.type ) - return resp else: return redirect(self.get_self_url()) @@ -1831,15 +1835,15 @@ class InvoiceDownload(EventPermissionRequiredMixin, View): return redirect(self.get_order_url()) try: - resp = FileResponse(self.invoice.file.file, content_type='application/pdf') + return FileResponse( + self.invoice.file.file, + filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number)), + content_type='application/pdf' + ) except FileNotFoundError: invoice_pdf_task.apply(args=(self.invoice.pk,)) return self.get(request, *args, **kwargs) - resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number)) - resp._csp_ignore = True # Some browser's PDF readers do not work with CSP - return resp - class OrderExtend(OrderView): permission = 'event.orders:write' diff --git a/src/pretix/control/views/pdf.py b/src/pretix/control/views/pdf.py index 0ad4e1c0e2..03f21c7319 100644 --- a/src/pretix/control/views/pdf.py +++ b/src/pretix/control/views/pdf.py @@ -263,12 +263,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView): resp = HttpResponse(data, content_type=mimet) ftype = fname.split(".")[-1] - if settings.DEBUG: - # attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging - resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype) - resp._csp_ignore = True - else: - resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype) + resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype) return resp elif "data" in request.POST: if cf: @@ -309,6 +304,5 @@ class FontsCSSView(TemplateView): class PdfView(TemplateView): def get(self, request, *args, **kwargs): cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf") - resp = FileResponse(cf.file, content_type='application/pdf') - resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename) + resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf') return resp diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index c6f566371a..99d027be0d 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -855,9 +855,13 @@ class AnswerDownload(EventViewMixin, View): return Http404() ftype, _ = mimetypes.guess_type(answer.file.name) - resp = FileResponse(answer.file, content_type=ftype or 'application/binary') - resp['Content-Disposition'] = 'attachment; filename="{}-cart-{}"'.format( + filename = '{}-cart-{}'.format( self.request.event.slug.upper(), os.path.basename(answer.file.name).split('.', 1)[1] - ).encode("ascii", "ignore") + ) + resp = FileResponse( + answer.file, + filename=filename, + content_type=ftype or 'application/binary' + ) return resp diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 964974fc8e..206de6ba7f 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1220,30 +1220,26 @@ class OrderDownloadMixin: resp = HttpResponseRedirect(value.file.file.read()) return resp else: - resp = FileResponse(value.file.file, content_type=value.type) - if self.order_position.subevent: - # Subevent date in filename improves accessibility e.g. for screen reader users - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}-{}{}"'.format( - self.request.event.slug.upper(), self.order.code, self.order_position.positionid, - self.order_position.subevent.date_from.strftime('%Y_%m_%d'), - self.output.identifier, value.extension - ) - else: - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( - self.request.event.slug.upper(), self.order.code, self.order_position.positionid, - self.output.identifier, value.extension - ) - return resp + name_parts = ( + self.request.event.slug.upper(), + self.order.code, + str(self.order_position.positionid), + self.order_position.subevent.date_from.strftime('%Y_%m_%d') if self.order_position.subevent else None, + self.output.identifier + ) + filename = "-".join(filter(None, name_parts)) + value.extension + return FileResponse(value.file.file, filename=filename, content_type=value.type) elif isinstance(value, CachedCombinedTicket): if value.type == 'text/uri-list': resp = HttpResponseRedirect(value.file.file.read()) return resp else: - resp = FileResponse(value.file.file, content_type=value.type) - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( - self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension + return FileResponse( + value.file.file, + filename="{}-{}-{}{}".format( + self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension), + content_type=value.type ) - return resp else: return redirect(self.get_self_url()) @@ -1383,13 +1379,14 @@ class InvoiceDownload(EventViewMixin, OrderDetailMixin, View): return redirect(self.get_order_url()) try: - resp = FileResponse(invoice.file.file, content_type='application/pdf') + return FileResponse( + invoice.file.file, + filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number)), + content_type='application/pdf' + ) except FileNotFoundError: invoice_pdf_task.apply(args=(invoice.pk,)) return self.get(request, *args, **kwargs) - resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number)) - resp._csp_ignore = True # Some browser's PDF readers do not work with CSP - return resp class OrderChangeMixin: