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 <mira@teamwiki.de>

---------

Co-authored-by: luelista <mira@teamwiki.de>
This commit is contained in:
Richard Schreiber
2026-04-14 09:12:09 +02:00
committed by GitHub
parent 059ff6c99b
commit 5682d3ed56
7 changed files with 90 additions and 82 deletions

View File

@@ -381,12 +381,15 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp return resp
else: else:
resp = FileResponse(ct.file.file, content_type=ct.type) return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( ct.file.file,
self.request.event.slug.upper(), order.code, filename='{}-{}-{}{}'.format(
provider.identifier, ct.extension 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']) @action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs): def mark_paid(self, request, **kwargs):
@@ -1303,14 +1306,17 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
raise NotFound() raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name) ftype, ignored = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary') return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format( answer.file,
self.request.event.slug.upper(), filename='{}-{}-{}-{}"'.format(
pos.order.code, self.request.event.slug.upper(),
pos.positionid, pos.order.code,
os.path.basename(answer.file.name).split('.', 1)[1] 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"]) @action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs): def printlog(self, request, **kwargs):
@@ -1365,15 +1371,18 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
if hasattr(image_file, 'seek'): if hasattr(image_file, 'seek'):
image_file.seek(0) image_file.seek(0)
resp = FileResponse(image_file, content_type=ftype or 'application/binary') return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format( image_file,
self.request.event.slug.upper(), filename='{}-{}-{}-{}.{}'.format(
pos.order.code, self.request.event.slug.upper(),
pos.positionid, pos.order.code,
key, pos.positionid,
extension, key,
extension,
),
as_attachment=True,
content_type=ftype or 'application/binary'
) )
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)') @action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs): 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') resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp return resp
else: else:
resp = FileResponse(ct.file.file, content_type=ct.type) return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( ct.file.file,
self.request.event.slug.upper(), pos.order.code, pos.positionid, filename='{}-{}-{}-{}{}'.format(
provider.identifier, ct.extension 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']) @action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs): def regenerate_secrets(self, request, **kwargs):
@@ -1986,9 +1998,12 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
if not invoice.file: if not invoice.file:
raise RetryException() raise RetryException()
resp = FileResponse(invoice.file.file, content_type='application/pdf') return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number) invoice.file.file,
return resp filename='{}.pdf"'.format(invoice.number),
as_attachment=True,
content_type='application/pdf'
)
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs): def transmit(self, request, **kwargs):

View File

@@ -763,12 +763,7 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
fname, ftype, fcontent = build_preview_invoice_pdf(request.event) fname, ftype, fcontent = build_preview_invoice_pdf(request.event)
resp = HttpResponse(fcontent, content_type=ftype) resp = HttpResponse(fcontent, content_type=ftype)
if settings.DEBUG: resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
# 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)
return resp return resp

View File

@@ -300,5 +300,4 @@ class SysReportView(AdministratorPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data) resp = HttpResponse(data)
resp['Content-Type'] = mime resp['Content-Type'] = mime
resp['Content-Disposition'] = 'inline; filename="{}"'.format(name) resp['Content-Disposition'] = 'inline; filename="{}"'.format(name)
resp._csp_ignore = True
return resp return resp

View File

@@ -710,22 +710,26 @@ class OrderDownload(AsyncAction, OrderView):
resp = HttpResponseRedirect(value.file.file.read()) resp = HttpResponseRedirect(value.file.file.read())
return resp return resp
else: else:
resp = FileResponse(value.file.file, content_type=value.type) return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( value.file.file,
self.request.event.slug.upper(), self.order.code, self.order_position.positionid, filename='{}-{}-{}-{}{}'.format(
self.output.identifier, value.extension 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): elif isinstance(value, CachedCombinedTicket):
if value.type == 'text/uri-list': if value.type == 'text/uri-list':
resp = HttpResponseRedirect(value.file.file.read()) resp = HttpResponseRedirect(value.file.file.read())
return resp return resp
else: else:
resp = FileResponse(value.file.file, content_type=value.type) return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( value.file.file,
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension filename='{}-{}-{}{}'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
),
content_type=value.type
) )
return resp
else: else:
return redirect(self.get_self_url()) return redirect(self.get_self_url())
@@ -1831,15 +1835,15 @@ class InvoiceDownload(EventPermissionRequiredMixin, View):
return redirect(self.get_order_url()) return redirect(self.get_order_url())
try: 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: except FileNotFoundError:
invoice_pdf_task.apply(args=(self.invoice.pk,)) invoice_pdf_task.apply(args=(self.invoice.pk,))
return self.get(request, *args, **kwargs) 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): class OrderExtend(OrderView):
permission = 'event.orders:write' permission = 'event.orders:write'

View File

@@ -263,12 +263,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data, content_type=mimet) resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1] ftype = fname.split(".")[-1]
if settings.DEBUG: resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
# 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)
return resp return resp
elif "data" in request.POST: elif "data" in request.POST:
if cf: if cf:
@@ -309,6 +304,5 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView): class PdfView(TemplateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf") cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
resp = FileResponse(cf.file, content_type='application/pdf') resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
return resp return resp

View File

@@ -855,9 +855,13 @@ class AnswerDownload(EventViewMixin, View):
return Http404() return Http404()
ftype, _ = mimetypes.guess_type(answer.file.name) ftype, _ = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary') filename = '{}-cart-{}'.format(
resp['Content-Disposition'] = 'attachment; filename="{}-cart-{}"'.format(
self.request.event.slug.upper(), self.request.event.slug.upper(),
os.path.basename(answer.file.name).split('.', 1)[1] 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 return resp

View File

@@ -1220,30 +1220,26 @@ class OrderDownloadMixin:
resp = HttpResponseRedirect(value.file.file.read()) resp = HttpResponseRedirect(value.file.file.read())
return resp return resp
else: else:
resp = FileResponse(value.file.file, content_type=value.type) name_parts = (
if self.order_position.subevent: self.request.event.slug.upper(),
# Subevent date in filename improves accessibility e.g. for screen reader users self.order.code,
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}-{}{}"'.format( str(self.order_position.positionid),
self.request.event.slug.upper(), self.order.code, self.order_position.positionid, self.order_position.subevent.date_from.strftime('%Y_%m_%d') if self.order_position.subevent else None,
self.order_position.subevent.date_from.strftime('%Y_%m_%d'), self.output.identifier
self.output.identifier, value.extension )
) filename = "-".join(filter(None, name_parts)) + value.extension
else: return FileResponse(value.file.file, filename=filename, 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 resp
elif isinstance(value, CachedCombinedTicket): elif isinstance(value, CachedCombinedTicket):
if value.type == 'text/uri-list': if value.type == 'text/uri-list':
resp = HttpResponseRedirect(value.file.file.read()) resp = HttpResponseRedirect(value.file.file.read())
return resp return resp
else: else:
resp = FileResponse(value.file.file, content_type=value.type) return FileResponse(
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( value.file.file,
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension filename="{}-{}-{}{}".format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension),
content_type=value.type
) )
return resp
else: else:
return redirect(self.get_self_url()) return redirect(self.get_self_url())
@@ -1383,13 +1379,14 @@ class InvoiceDownload(EventViewMixin, OrderDetailMixin, View):
return redirect(self.get_order_url()) return redirect(self.get_order_url())
try: 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: except FileNotFoundError:
invoice_pdf_task.apply(args=(invoice.pk,)) invoice_pdf_task.apply(args=(invoice.pk,))
return self.get(request, *args, **kwargs) 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: class OrderChangeMixin: