diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index d63668dcfe..e88a85135b 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -12,11 +12,10 @@ from django.db import models from django.db.models import F, Sum from django.db.models.signals import post_delete from django.dispatch import receiver +from django.urls import reverse from django.utils.crypto import get_random_string from django.utils.encoding import escape_uri_path from django.utils.functional import cached_property -from django.utils.html import escape -from django.utils.safestring import mark_safe from django.utils.timezone import make_aware, now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django_countries.fields import CountryField @@ -493,7 +492,19 @@ class QuestionAnswer(models.Model): ) @property - def file_link(self): + def backend_file_url(self): + if self.file: + if self.orderposition: + return reverse('control:event.order.download.answer', kwargs={ + 'code': self.orderposition.order.code, + 'event': self.orderposition.order.event.slug, + 'organizer': self.orderposition.order.event.organizer.slug, + 'answer': self.pk, + }) + return "" + + @property + def frontend_file_url(self): from pretix.multidomain.urlreverse import eventreverse if self.file: @@ -508,12 +519,13 @@ class QuestionAnswer(models.Model): 'answer': self.pk, }) - return mark_safe("{}".format( - url, - escape(self.file.name.split('.', 1)[-1]) - )) + return url return "" + @property + def file_name(self): + return self.file.name.split('.', 1)[-1] + def __str__(self): if self.question.type == Question.TYPE_BOOLEAN and self.answer == "True": return str(_("Yes")) diff --git a/src/pretix/base/templatetags/safelink.py b/src/pretix/base/templatetags/safelink.py index 3f2e7eb350..6004db19ab 100644 --- a/src/pretix/base/templatetags/safelink.py +++ b/src/pretix/base/templatetags/safelink.py @@ -1,5 +1,7 @@ from django import template +from pretix.helpers.safedownload import get_token + from ..views.redirect import safelink as sl register = template.Library() @@ -8,3 +10,8 @@ register = template.Library() @register.simple_tag def safelink(url): return sl(url) + + +@register.simple_tag +def answer_token(request, answer): + return get_token(request, answer) diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index cb502a6265..6e5daba9ca 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -2,6 +2,7 @@ {% load i18n %} {% load bootstrap3 %} {% load eventurl %} +{% load safelink %} {% block title %} {% blocktrans trimmed with code=order.code %} Order details: {{ code }} @@ -210,7 +211,10 @@
{% if q.answer %} {% if q.answer.file %} - {{ q.answer.file_link }} + + + {{ q.answer.file_name }} + {% else %} {{ q.answer|linebreaksbr }} {% endif %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index fb1009daa4..6134fc3c8b 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -128,6 +128,9 @@ urlpatterns = [ name='event.order.regeninvoice'), url(r'^orders/(?P[0-9A-Z]+)/invoices/(?P\d+)/reissue$', orders.OrderInvoiceReissue.as_view(), name='event.order.reissueinvoice'), + url(r'^orders/(?P[0-9A-Z]+)/answer/(?P[^/]+)/$', + orders.AnswerDownload.as_view(), + name='event.order.download.answer'), url(r'^orders/(?P[0-9A-Z]+)/extend$', orders.OrderExtend.as_view(), name='event.order.extend'), url(r'^orders/(?P[0-9A-Z]+)/contact$', orders.OrderContactChange.as_view(), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index fa2fbca0dd..062bcc8b42 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1,3 +1,5 @@ +import mimetypes +import os from datetime import timedelta import pytz @@ -6,7 +8,7 @@ from django.contrib import messages from django.core.urlresolvers import reverse from django.db.models import Count from django.http import FileResponse, Http404, HttpResponseNotAllowed -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.timezone import now @@ -19,8 +21,8 @@ from i18nfield.strings import LazyI18nString from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedFile, CachedTicket, Invoice, InvoiceAddress, - Item, ItemVariation, LogEntry, Order, Quota, generate_position_secret, - generate_secret, + Item, ItemVariation, LogEntry, Order, QuestionAnswer, Quota, + generate_position_secret, generate_secret, ) from pretix.base.models.event import SubEvent from pretix.base.services.export import export @@ -42,6 +44,7 @@ from pretix.control.forms.orders import ( OrderMailForm, OrderPositionAddForm, OrderPositionChangeForm, ) from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.helpers.safedownload import check_token from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.signals import question_form_fields @@ -741,6 +744,29 @@ class OrderEmailHistory(EventPermissionRequiredMixin, OrderViewMixin, ListView): return qs +class AnswerDownload(EventPermissionRequiredMixin, OrderViewMixin, ListView): + permission = 'can_view_orders' + + def get(self, request, *args, **kwargs): + answid = kwargs.get('answer') + token = request.GET.get('token', '') + + answer = get_object_or_404(QuestionAnswer, orderposition__order=self.order, id=answid) + if not answer.file: + raise Http404() + if not check_token(request, answer, token): + raise Http404(_("This link is no longer valid. Please go back, refresh the page, and try again.")) + + 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(), self.order.code, + answer.orderposition.positionid, + os.path.basename(answer.file.name).split('.', 1)[1] + ) + return resp + + class OverView(EventPermissionRequiredMixin, TemplateView): template_name = 'pretixcontrol/orders/overview.html' permission = 'can_view_orders' diff --git a/src/pretix/helpers/safedownload.py b/src/pretix/helpers/safedownload.py new file mode 100644 index 0000000000..3446a67916 --- /dev/null +++ b/src/pretix/helpers/safedownload.py @@ -0,0 +1,18 @@ +import hashlib + +from django.core.signing import BadSignature, TimestampSigner + + +def get_token(request, answer): + payload = '{}:{}'.format(request.session.session_key, answer.pk) + signer = TimestampSigner() + return signer.sign(hashlib.sha1(payload.encode()).hexdigest()) + + +def check_token(request, answer, token): + payload = hashlib.sha1('{}:{}'.format(request.session.session_key, answer.pk).encode()).hexdigest() + signer = TimestampSigner() + try: + return payload == signer.unsign(token, max_age=3600 * 24) + except BadSignature: + return False diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index f0c5484a0d..dd4c13d343 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -1,5 +1,6 @@ {% load i18n %} {% load eventurl %} +{% load safelink %} {% for line in cart.positions %}
@@ -33,7 +34,10 @@
{% if q.answer %} {% if q.answer.file %} - {{ q.answer.file_link }} + + + {{ q.answer.file_name }} + {% else %} {{ q.answer|linebreaksbr }} {% endif %} diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 8dac0d305d..86921e5ae8 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -22,6 +22,7 @@ from pretix.base.services.tickets import ( get_cachedticket_for_order, get_cachedticket_for_position, ) from pretix.base.signals import register_ticket_outputs +from pretix.helpers.safedownload import check_token from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.checkout import InvoiceAddressForm from pretix.presale.views import CartMixin, EventViewMixin @@ -502,11 +503,15 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View): class AnswerDownload(EventViewMixin, OrderDetailMixin, View): def get(self, request, *args, **kwargs): answid = kwargs.get('answer') + token = request.GET.get('token', '') + answer = get_object_or_404(QuestionAnswer, orderposition__order=self.order, id=answid) if not answer.file: - return Http404() + raise Http404() + if not check_token(request, answer, token): + raise Http404(_("This link is no longer valid. Please go back, refresh the page, and try again.")) - ftype, _ = mimetypes.guess_type(answer.file.name) + 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(), self.order.code, diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index b38e3b09f8..1b1b8ad20f 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -202,6 +202,7 @@ event_permission_urls = [ ("can_change_orders", "orders/FOO/change", 200), ("can_change_orders", "orders/FOO/comment", 405), ("can_change_orders", "orders/FOO/locale", 200), + ("can_view_orders", "orders/FOO/answer/5/", 404), ("can_change_vouchers", "vouchers/add", 200), ("can_change_orders", "requiredactions/", 200), ("can_change_vouchers", "vouchers/bulk_add", 200), diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 075f61d294..4d85d835ad 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -1,7 +1,9 @@ import datetime +import re from decimal import Decimal from bs4 import BeautifulSoup +from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.utils.timezone import now @@ -418,3 +420,39 @@ class OrdersTest(TestCase): assert self.order.payment_fee == Decimal('12.00') assert self.order.total == Decimal('23.00') + self.order.payment_fee assert self.order.invoices.count() == 3 + + def test_answer_download_token(self): + q = self.event.questions.create(question="Foo", type="F") + q.items.add(self.ticket) + a = self.ticket_pos.answers.create(question=q, answer="file") + val = SimpleUploadedFile("testfile.txt", b"file_content") + a.file.save("testfile.txt", val) + a.save() + + self.event.settings.set('ticket_download', True) + del self.event.settings['ticket_download_date'] + response = self.client.get( + '/%s/%s/order/%s/%s/answer/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret, a.pk) + ) + assert response.status_code == 404 + + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + match = re.search(r"\?token=([^'\"&]+)", response.rendered_content) + assert match + + response = self.client.get( + '/%s/%s/order/%s/%s/answer/%s/?token=%s' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret, a.pk, match.group(1)) + ) + assert response.status_code == 200 + + client2 = self.client_class() + response = client2.get( + '/%s/%s/order/%s/%s/answer/%s/?token=%s' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret, a.pk, match.group(1)) + ) + assert response.status_code == 404