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 @@
[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