mirror of
https://github.com/pretix/pretix.git
synced 2026-09-30 19:04:43 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b28dd81e | ||
|
|
169110e741 | ||
|
|
91d7459ecd | ||
|
|
686e4d54b1 | ||
|
|
6687d475f2 | ||
|
|
7ef22971c6 | ||
|
|
d2ba4b7733 | ||
|
|
81e004e6c8 | ||
|
|
d8fe665798 | ||
|
|
b7117bb3a3 | ||
|
|
912851aa87 | ||
|
|
5440508be0 | ||
|
|
7025159f6b | ||
|
|
42cedc7306 | ||
|
|
11ac2cc91c | ||
|
|
2bd0a341d6 | ||
|
|
6bec15d12a |
+1
-1
@@ -44,7 +44,7 @@ dependencies = [
|
||||
"django-filter==25.1",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formtools==2.6.1",
|
||||
"django-hierarkey==2.0.*,>=2.0.1",
|
||||
"django-hierarkey==2.0.*,>=2.0.2",
|
||||
"django-hijack==3.7.*",
|
||||
"django-i18nfield==1.11.*",
|
||||
"django-libsass==0.9",
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2026.6.0"
|
||||
__version__ = "2026.6.3"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
def get_session_key_for_api_auth(user, auth):
|
||||
if user.is_authenticated:
|
||||
return f'api-upload-User-{user.pk}'
|
||||
else:
|
||||
return f'api-upload-{str(type(auth))}-{auth.pk}'
|
||||
|
||||
|
||||
def get_session_key_for_api_request(request):
|
||||
return get_session_key_for_api_auth(request.user, request.auth)
|
||||
@@ -101,6 +101,10 @@ class OAuthAccessToken(AbstractAccessToken):
|
||||
self.expires = now() - timedelta(hours=1)
|
||||
self.save(update_fields=['expires'])
|
||||
|
||||
def is_valid(self, scopes=None):
|
||||
# Can maybe be removed after upgrading django-oauth-toolkit to 3.4.1
|
||||
return super().is_valid(scopes) and self.application.is_usable(None)
|
||||
|
||||
|
||||
class OAuthRefreshToken(AbstractRefreshToken):
|
||||
application = models.ForeignKey(
|
||||
|
||||
@@ -37,6 +37,8 @@ from collections import OrderedDict
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.auth.utils import get_session_key_for_api_request
|
||||
|
||||
|
||||
def remove_duplicates_from_list(data):
|
||||
return list(OrderedDict.fromkeys(data))
|
||||
@@ -83,10 +85,16 @@ class UploadedFileField(serializers.Field):
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
|
||||
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
|
||||
# also be harmless because all files with this key are expired one day after deployment of this fix
|
||||
# and no new files with this key are created
|
||||
pass
|
||||
elif cf.session_key != get_session_key_for_api_request(request):
|
||||
self.fail('not_found')
|
||||
except (ValidationError, IndexError): # invalid uuid
|
||||
self.fail('not_found')
|
||||
except CachedFile.DoesNotExist:
|
||||
|
||||
@@ -41,6 +41,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.auth.utils import get_session_key_for_api_request
|
||||
from pretix.api.serializers import CompatibleJSONField
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
@@ -257,16 +258,21 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
if data['answer'] == 'file:keep':
|
||||
return data
|
||||
try:
|
||||
ao = self.context["request"].user or self.context["request"].auth
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(ao))}-{ao.pk}',
|
||||
file__isnull=False,
|
||||
pk=data['answer'][len("file:"):],
|
||||
)
|
||||
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
|
||||
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
|
||||
# also be harmless because all files with this key are expired one day after deployment of this fix
|
||||
# and no new files with this key are created
|
||||
pass
|
||||
elif cf.session_key != get_session_key_for_api_request(self.context["request"]):
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
|
||||
except (ValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
|
||||
|
||||
allowed_types = (
|
||||
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||
|
||||
@@ -50,6 +50,7 @@ from rest_framework.generics import ListAPIView
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.auth.utils import get_session_key_for_api_auth
|
||||
from pretix.api.serializers.checkin import (
|
||||
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
|
||||
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
|
||||
@@ -329,10 +330,16 @@ with scopes_disabled():
|
||||
def _handle_file_upload(data, user, auth):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
|
||||
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
|
||||
# also be harmless because all files with this key are expired one day after deployment of this fix
|
||||
# and no new files with this key are created
|
||||
pass
|
||||
elif cf.session_key != get_session_key_for_api_auth(user, auth):
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
|
||||
@@ -33,6 +33,7 @@ from rest_framework.views import APIView
|
||||
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||
from pretix.api.auth.token import TeamTokenAuthentication
|
||||
from pretix.api.auth.utils import get_session_key_for_api_request
|
||||
from pretix.base.models import CachedFile
|
||||
from pretix.helpers.images import (
|
||||
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
|
||||
@@ -78,7 +79,7 @@ class UploadView(APIView):
|
||||
web_download=False,
|
||||
filename=file_obj.name,
|
||||
type=content_type,
|
||||
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
|
||||
session_key=get_session_key_for_api_request(request)
|
||||
)
|
||||
cf.file.save(file_obj.name, file_obj)
|
||||
cf.save()
|
||||
|
||||
@@ -587,12 +587,16 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
image = ImageOps.exif_transpose(image)
|
||||
|
||||
if f._cropdata:
|
||||
image = image.crop((
|
||||
f._cropdata.get('x', 0),
|
||||
f._cropdata.get('y', 0),
|
||||
f._cropdata.get('x', 0) + f._cropdata.get('width', image.width),
|
||||
f._cropdata.get('y', 0) + f._cropdata.get('height', image.height),
|
||||
))
|
||||
left = int(f._cropdata.get('x', 0))
|
||||
top = int(f._cropdata.get('y', 0))
|
||||
right = left + int(f._cropdata.get('width', image.width))
|
||||
bottom = top + int(f._cropdata.get('height', image.height))
|
||||
if left >= image.width or top >= image.height or right > image.width or bottom > image.height:
|
||||
raise ValidationError(
|
||||
self.error_messages['max_dimension'],
|
||||
code='max_dimension',
|
||||
)
|
||||
image = image.crop((left, top, right, bottom))
|
||||
with BytesIO() as output:
|
||||
# This might use a lot of memory, but temporary files are not a good option since
|
||||
# we don't control the cleanup
|
||||
|
||||
@@ -1070,10 +1070,8 @@ class Item(LoggedModel):
|
||||
|
||||
replace_year = valid_until.year
|
||||
replace_month = valid_until.month + self.validity_dynamic_duration_months
|
||||
|
||||
while replace_month > 12:
|
||||
replace_month -= 12
|
||||
replace_year += 1
|
||||
replace_year += (replace_month - 1) // 12
|
||||
replace_month = ((replace_month - 1) % 12) + 1
|
||||
max_day = calendar.monthrange(replace_year, replace_month)[1]
|
||||
replace_date = date(
|
||||
year=replace_year,
|
||||
|
||||
@@ -1074,7 +1074,7 @@ class Renderer:
|
||||
fontsize = float(o['fontsize'])
|
||||
height = float(o['height']) * mm
|
||||
width = float(o['width']) * mm
|
||||
while True:
|
||||
for _i in range(25): # try adapting the font size at most 25 times
|
||||
p, ad, lineheight = self._text_paragraph(op, order, o, override_fontsize=fontsize)
|
||||
w, h = p.wrapOn(canvas, width, 1000 * mm)
|
||||
widths = p.getActualLineWidths0()
|
||||
|
||||
@@ -830,9 +830,10 @@ class CancelSettingsForm(SettingsForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.obj.settings.giftcard_expiry_years is not None:
|
||||
self.fields['cancel_allow_user_paid_refund_as_giftcard'].help_text = gettext(
|
||||
'You have configured gift cards to be valid {} years plus the year the gift card is issued in.'
|
||||
).format(self.obj.settings.giftcard_expiry_years)
|
||||
self.fields['cancel_allow_user_paid_refund_as_giftcard'].help_text = format_html(
|
||||
gettext('You have configured gift cards to be valid {} years plus the year the gift card is issued in.'),
|
||||
self.obj.settings.giftcard_expiry_years
|
||||
)
|
||||
|
||||
|
||||
class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.html import format_html
|
||||
from django.utils.html import conditional_escape, format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.modelimport_orders import get_order_import_columns
|
||||
@@ -66,7 +66,7 @@ class ProcessForm(forms.Form):
|
||||
widget=forms.Select(
|
||||
attrs={'data-static': 'true'}
|
||||
),
|
||||
help_text=c.help_text,
|
||||
help_text=conditional_escape(c.help_text),
|
||||
)
|
||||
|
||||
def get_columns(self):
|
||||
|
||||
@@ -364,7 +364,7 @@ class TeamForm(forms.ModelForm):
|
||||
for opt in pg.options
|
||||
],
|
||||
label=pg.label,
|
||||
help_text=pg.help_text,
|
||||
help_text=conditional_escape(pg.help_text),
|
||||
initial=initial,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
@@ -389,7 +389,7 @@ class TeamForm(forms.ModelForm):
|
||||
for opt in pg.options
|
||||
],
|
||||
label=pg.label,
|
||||
help_text=pg.help_text,
|
||||
help_text=conditional_escape(pg.help_text),
|
||||
initial=initial,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db.models.functions import Upper
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes.forms import SafeModelChoiceField
|
||||
|
||||
@@ -161,7 +162,7 @@ class VoucherForm(I18nModelForm):
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'data-seat-guid-field': '1'}),
|
||||
initial=self.instance.seat.seat_guid if self.instance.seat else '',
|
||||
help_text=str(self.instance.seat) if self.instance.seat else '',
|
||||
help_text=escape(str(self.instance.seat) if self.instance.seat else ''),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -414,9 +414,12 @@ def event_index_widgets_lazy(request, organizer, event):
|
||||
except SubEvent.DoesNotExist:
|
||||
pass
|
||||
|
||||
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
|
||||
request=request)
|
||||
widgets = []
|
||||
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False):
|
||||
widgets.extend(result)
|
||||
if can_view_orders:
|
||||
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False):
|
||||
widgets.extend(result)
|
||||
|
||||
return JsonResponse({'widgets': widgets})
|
||||
|
||||
|
||||
@@ -1558,7 +1558,7 @@ class WidgetSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
return ctx
|
||||
|
||||
|
||||
class QuickSetupView(FormView):
|
||||
class QuickSetupView(EventPermissionRequiredMixin, FormView):
|
||||
template_name = 'pretixcontrol/event/quick_setup.html'
|
||||
permission = 'event.settings.general:write'
|
||||
form_class = QuickSetupForm
|
||||
|
||||
@@ -251,6 +251,13 @@ def monkeypatch_reportlab_imagereader():
|
||||
utils.ImageReader.__init__ = new_init
|
||||
|
||||
|
||||
def monkeypatch_json_constants():
|
||||
from json.decoder import _CONSTANTS # noqa
|
||||
del _CONSTANTS['-Infinity']
|
||||
del _CONSTANTS['Infinity']
|
||||
del _CONSTANTS['NaN']
|
||||
|
||||
|
||||
def monkeypatch_all_at_ready():
|
||||
monkeypatch_vobject_performance()
|
||||
monkeypatch_pillow_safer()
|
||||
@@ -258,3 +265,4 @@ def monkeypatch_all_at_ready():
|
||||
monkeypatch_urllib3_ssrf_protection()
|
||||
monkeypatch_cookie_morsel()
|
||||
monkeypatch_reportlab_imagereader()
|
||||
monkeypatch_json_constants()
|
||||
|
||||
@@ -33,7 +33,7 @@ from django.contrib.auth.password_validation import (
|
||||
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||
from django.core import signing
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
|
||||
@@ -83,7 +83,8 @@ class AuthenticationForm(forms.Form):
|
||||
self.request = request
|
||||
self.customer_cache = None
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['password'].help_text = "<a target='_blank' href='{}'>{}</a>".format(
|
||||
self.fields['password'].help_text = format_html(
|
||||
"<a target='_blank' href='{}'>{}</a>",
|
||||
eventreverse_absolute(False, 'presale:organizer.customer.resetpw', kwargs={
|
||||
'organizer': request.organizer.slug,
|
||||
}),
|
||||
|
||||
@@ -14,10 +14,23 @@
|
||||
{% trans "Log out" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% abseventurl request.organizer "presale:organizer.customer.login" %}{% if request.resolver_match.url_name != "organizer.customer.login" %}?next={% if request.event_domain %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ request.path|urlencode }}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}{% if request.event_domain %}&request_cross_domain_customer_auth=true{% endif %}">
|
||||
<span class="fa fa-sign-in" aria-hidden="true"></span>
|
||||
{% trans "Log in" %}</a>
|
||||
|
||||
<form
|
||||
{% if request.event_domain %}
|
||||
action="{% abseventurl request.event "presale:event.customer.loginstart" %}" method="post"
|
||||
{% else %}
|
||||
action="{% abseventurl request.organizer "presale:organizer.customer.login" %}" method="get"
|
||||
{% endif %}
|
||||
class="helper-display-inline">
|
||||
{% if request.event_domain %}
|
||||
{% csrf_token %}
|
||||
{% endif %}
|
||||
{% if request.resolver_match.url_name != "organizer.customer.login" %}
|
||||
<input type="hidden" name="next" value="{% if request.event_domain %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ request.path }}?{{ request.META.QUERY_STRING }}">
|
||||
{% endif %}
|
||||
<button class="btn btn-link" type="submit">
|
||||
<span class="fa fa-sign-in" aria-hidden="true"></span>
|
||||
{% trans "Log in" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
@@ -98,6 +98,7 @@ event_patterns = [
|
||||
re_path(r'unlock/(?P<hash>[a-z0-9]{64})/$', pretix.presale.views.user.UnlockHashView.as_view(),
|
||||
name='event.payment.unlock'),
|
||||
re_path(r'resend/$', pretix.presale.views.user.ResendLinkView.as_view(), name='event.resend_link'),
|
||||
re_path(r'^account/loginstart$', pretix.presale.views.customer.LoginStartView.as_view(), name='event.customer.loginstart'),
|
||||
|
||||
re_path(r'^favicon.ico/?$',
|
||||
pretix.presale.views.organizer.OrganizerFavicon.as_view(),
|
||||
|
||||
@@ -151,7 +151,9 @@ def add_customer_to_request(request):
|
||||
else:
|
||||
parent_session_key = otpstore.get(f'customer_cross_domain_auth_{request.organizer.pk}')
|
||||
|
||||
if parent_session_key: # not already invalidated, expired, …
|
||||
expected_nonce = request.session.pop('cross_domain_customer_auth_nonce', None)
|
||||
found_nonce = request.GET.get("cross_domain_customer_auth_nonce")
|
||||
if parent_session_key and expected_nonce and expected_nonce == found_nonce: # not already invalidated, expired, …
|
||||
# Make sure the OTP can't be used again
|
||||
otpstore.delete()
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
#
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import Http404
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -29,6 +31,7 @@ from django.views.generic import View
|
||||
|
||||
from pretix.base.services.cart import CartError
|
||||
from pretix.base.signals import validate_cart
|
||||
from pretix.base.views.tasks import AsyncAction
|
||||
from pretix.helpers.http import redirect_to_url
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from pretix.presale.checkoutflow import get_checkout_flow
|
||||
@@ -51,7 +54,12 @@ class CheckoutView(View):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
|
||||
if not cart_exists(request) and "async_id" not in request.GET:
|
||||
is_asyncaction_call = (
|
||||
request.method == "GET" and
|
||||
'async_id' in request.GET and
|
||||
settings.HAS_CELERY
|
||||
)
|
||||
if not cart_exists(request) and not is_asyncaction_call:
|
||||
messages.error(request, _("Your cart is empty"))
|
||||
return self.redirect(self.get_index_url(self.request))
|
||||
|
||||
@@ -78,9 +86,13 @@ class CheckoutView(View):
|
||||
utm_params = {k: v for k, v in request.GET.items() if k.startswith("utm_")}
|
||||
return self.redirect(step.get_step_url(request) + '?' + urlencode(utm_params))
|
||||
is_selected = (step.identifier == kwargs.get('step', ''))
|
||||
if "async_id" not in request.GET and not is_selected and not step.is_completed(request, warn=not is_selected):
|
||||
|
||||
if not is_asyncaction_call and not is_selected and not step.is_completed(request, warn=not is_selected):
|
||||
return self.redirect(step.get_step_url(request))
|
||||
if is_selected:
|
||||
if is_asyncaction_call and not isinstance(step, AsyncAction):
|
||||
# This could be used to circumvent validation otherwise
|
||||
raise SuspiciousOperation("Received ?async_id for a step that is not an AsyncAction")
|
||||
if request.method.lower() in self.http_method_names:
|
||||
handler = getattr(step, request.method.lower(), self.http_method_not_allowed)
|
||||
else:
|
||||
|
||||
@@ -146,6 +146,7 @@ class LoginView(RedirectBackMixin, FormView):
|
||||
u = urlparse(url)
|
||||
qsl = parse_qs(u.query)
|
||||
qsl['cross_domain_customer_auth'] = otp
|
||||
qsl['cross_domain_customer_auth_nonce'] = self.request.GET.get("request_cross_domain_customer_auth_nonce", "")
|
||||
url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment))
|
||||
|
||||
return url
|
||||
@@ -705,6 +706,7 @@ class SSOLoginView(RedirectBackMixin, View):
|
||||
request.session[f'pretix_customerauth_{self.provider.pk}_nonce'] = nonce
|
||||
request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin'] = popup_origin
|
||||
request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'] = self.request.GET.get("request_cross_domain_customer_auth") == "true"
|
||||
request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_nonce'] = self.request.GET.get("request_cross_domain_customer_auth_nonce")
|
||||
redirect_uri = eventreverse_absolute(self.request.organizer, 'presale:organizer.customer.login.return', kwargs={
|
||||
'provider': self.provider.pk
|
||||
})
|
||||
@@ -955,6 +957,28 @@ class SSOLoginReturnView(RedirectBackMixin, View):
|
||||
u = urlparse(url)
|
||||
qsl = parse_qs(u.query)
|
||||
qsl['cross_domain_customer_auth'] = otp
|
||||
qsl['cross_domain_customer_auth_nonce'] = self.request.session.get(f'pretix_customerauth_{self.provider.pk}_cross_domain_nonce', '')
|
||||
url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment))
|
||||
|
||||
return url
|
||||
|
||||
|
||||
class LoginStartView(View):
|
||||
# When a login is initiated on a event-domain-level view, we need to carry the user to the organizer domain through
|
||||
# this POST request to be able to set a nonce on their current session. We can't just use a link, since then we'd
|
||||
# need to create sessions for every anonymous user of the ticketshop, which is too expensive.
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if getattr(self.request, 'domain_mode', 'system') not in (KnownDomain.MODE_ORG_ALT_DOMAIN, KnownDomain.MODE_EVENT_DOMAIN):
|
||||
raise Http404("Only active on event-level domains")
|
||||
|
||||
nonce = get_random_string(32)
|
||||
request.session['cross_domain_customer_auth_nonce'] = nonce
|
||||
query = {
|
||||
"next": request.POST.get("next", ""),
|
||||
"request_cross_domain_customer_auth_nonce": nonce,
|
||||
"request_cross_domain_customer_auth": "true",
|
||||
}
|
||||
return redirect_to_url(
|
||||
eventreverse_absolute(self.request.organizer, "presale:organizer.customer.login") + "?" + urlencode(query)
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ import os
|
||||
import re
|
||||
from collections import Counter, OrderedDict, defaultdict
|
||||
from decimal import Decimal
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -55,6 +55,7 @@ from django.http import (
|
||||
FileResponse, Http404, HttpResponseRedirect, JsonResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
@@ -117,8 +118,14 @@ class OrderDetailMixin(NoSearchIndexViewMixin):
|
||||
login_url = eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
|
||||
|
||||
if hasattr(self.request, "event_domain") and self.request.event_domain:
|
||||
next_url = quote(self.request.scheme + "://" + self.request.get_host() + self.request.get_full_path())
|
||||
return redirect_to_url(f'{login_url}?next={next_url}&request_cross_domain_customer_auth=true')
|
||||
nonce = get_random_string(32)
|
||||
self.request.session['cross_domain_customer_auth_nonce'] = nonce
|
||||
query = {
|
||||
"next": self.request.scheme + "://" + self.request.get_host() + self.request.get_full_path(),
|
||||
"request_cross_domain_customer_auth_nonce": nonce,
|
||||
"request_cross_domain_customer_auth": "true",
|
||||
}
|
||||
return redirect_to_url(f'{login_url}?{urlencode(query)}')
|
||||
|
||||
else:
|
||||
next_url = quote(self.request.get_full_path())
|
||||
|
||||
@@ -221,6 +221,11 @@ footer nav .btn-link {
|
||||
/*border-bottom: 2px solid $brand-primary;*/
|
||||
font-weight: bold;
|
||||
}
|
||||
.btn-link {
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
vertical-align: top;
|
||||
}
|
||||
img {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
@@ -653,6 +658,35 @@ h2 .label {
|
||||
}
|
||||
|
||||
|
||||
.helper-position-relative {
|
||||
position: relative;
|
||||
}
|
||||
.helper-display-block {
|
||||
display: block !important;
|
||||
}
|
||||
.helper-display-inline {
|
||||
display: inline !important;
|
||||
}
|
||||
.helper-display-inline-block {
|
||||
display: inline-block !important;
|
||||
}
|
||||
.helper-display-none-soft {
|
||||
display: none;
|
||||
}
|
||||
.helper-display-none {
|
||||
display: none !important;
|
||||
}
|
||||
.helper-width-auto {
|
||||
width: auto;
|
||||
}
|
||||
.helper-width-100 {
|
||||
width: 100%;
|
||||
}
|
||||
.helper-space-below {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
@import "_iframe.scss";
|
||||
@import "_a11y.scss";
|
||||
@import "_print.scss";
|
||||
|
||||
@@ -252,7 +252,7 @@ TEST_HISTORY_RES = {
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_list_list(token_client, organizer, event, clist, item, subevent, django_assert_num_queries):
|
||||
res = dict(TEST_LIST_RES)
|
||||
res["id"] = clist.pk
|
||||
@@ -422,7 +422,7 @@ def test_list_update(token_client, organizer, event, clist):
|
||||
assert cl.name == "VIP"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_list_all_items_positions(token_client, organizer, event, clist, clist_all, item, other_item, order, django_assert_num_queries):
|
||||
with scopes_disabled():
|
||||
p1 = dict(TEST_ORDERPOSITION1_RES)
|
||||
@@ -680,7 +680,7 @@ def _redeem(token_client, org, clist, p, body=None):
|
||||
), body or {}, format='json')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_query_load(token_client, organizer, clist, event, order, django_assert_max_num_queries):
|
||||
with scopes_disabled():
|
||||
p = order.positions.first().pk
|
||||
@@ -1367,7 +1367,7 @@ def test_redeem_addon_if_match_and_revoked_force(token_client, organizer, clist,
|
||||
assert ci.position == p
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_search(token_client, organizer, event, clist, clist_all, item, other_item, order, django_assert_max_num_queries):
|
||||
with scopes_disabled():
|
||||
p1 = dict(TEST_ORDERPOSITION1_RES)
|
||||
@@ -1399,7 +1399,7 @@ def test_checkin_pdf_data_requires_permission(token_client, event, team, organiz
|
||||
assert not resp.data['results'][0].get('pdf_data')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_expand(token_client, organizer, event, clist, clist_all, item, other_item, order, django_assert_max_num_queries):
|
||||
with scopes_disabled():
|
||||
op = order.positions.first()
|
||||
|
||||
@@ -214,7 +214,7 @@ def _redeem(token_client, org, clist, p, body=None, query='', headers={}):
|
||||
), body, format='json', headers={})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_query_load(token_client, organizer, clist, event, order, django_assert_max_num_queries):
|
||||
with scopes_disabled():
|
||||
p = order.positions.first()
|
||||
@@ -996,7 +996,7 @@ def test_redeem_conflicting_lists(token_client, organizer, clist, clist_all, eve
|
||||
assert resp.data == ['Selecting two check-in lists from the same event is unsupported.']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_search(token_client, organizer, event, clist, clist_all, item, other_item, order,
|
||||
django_assert_max_num_queries):
|
||||
with scopes_disabled():
|
||||
|
||||
@@ -1826,7 +1826,7 @@ def test_event_block_unblock_seat_bulk(token_client, organizer, event, seatingpl
|
||||
assert not s2.blocked
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_event_expand_seat_filter_and_querycount(token_client, organizer, event, seatingplan, item):
|
||||
event.settings.seating_minimal_distance = 2
|
||||
|
||||
|
||||
@@ -70,14 +70,27 @@ def admin_user(admin_team):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application():
|
||||
def app_developer():
|
||||
return User.objects.create_user('app-developer@example.org', 'app-developer')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client2():
|
||||
# We need a second test client instance to log in as the app developer user
|
||||
from django.test import Client
|
||||
return Client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application(app_developer):
|
||||
secret = get_random_string(32)
|
||||
a = OAuthApplication.objects.create(
|
||||
name="pretalx",
|
||||
redirect_uris="https://pretalx.com",
|
||||
client_type='confidential',
|
||||
client_secret=secret,
|
||||
authorization_grant_type='authorization-code'
|
||||
authorization_grant_type='authorization-code',
|
||||
user=app_developer,
|
||||
)
|
||||
a._cached_secret = secret
|
||||
a.save()
|
||||
@@ -703,6 +716,47 @@ def test_token_revoke_access_token(client, admin_user, organizer, application: O
|
||||
assert list(grant.organizers.all()) == [organizer]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_token_app_disabled(client, client2, admin_user, organizer, application: OAuthApplication, app_developer):
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
session = client.session
|
||||
session['pretix_auth_login_time'] = int(time.time())
|
||||
session.save()
|
||||
resp = client.get('/api/v1/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code' % (
|
||||
application.client_id, quote(application.redirect_uris)
|
||||
))
|
||||
assert resp.status_code == 200
|
||||
resp = client.post('/api/v1/oauth/authorize', data={
|
||||
'organizers': str(organizer.pk),
|
||||
'redirect_uri': application.redirect_uris,
|
||||
'scope': 'read write',
|
||||
'client_id': application.client_id,
|
||||
'response_type': 'code',
|
||||
'allow': 'Authorize',
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
assert resp['Location'].startswith('https://pretalx.com?code=')
|
||||
code = resp['Location'].split("=")[1]
|
||||
client.logout()
|
||||
resp = client.post('/api/v1/oauth/token', data={
|
||||
'code': code,
|
||||
'redirect_uri': application.redirect_uris,
|
||||
'grant_type': 'authorization_code',
|
||||
}, HTTP_AUTHORIZATION='Basic ' + base64.b64encode(
|
||||
('%s:%s' % (application.client_id, application._cached_secret)).encode()).decode())
|
||||
assert resp.status_code == 200
|
||||
data = json.loads(resp.content.decode())
|
||||
access_token = data['access_token']
|
||||
resp = client.get('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token)
|
||||
assert resp.status_code == 200
|
||||
|
||||
client2.login(email='app-developer@example.org', password='app-developer')
|
||||
client2.post(f'/control/settings/oauth/apps/{application.pk}/disable', {})
|
||||
|
||||
resp = client.get('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_revoke(client, admin_user, organizer, application: OAuthApplication):
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
@@ -1058,7 +1058,7 @@ def test_orderposition_list_limited_read(
|
||||
('/api/v1/organizers/{}/orderpositions/', "organizer")
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_orderposition_list(
|
||||
endpoint_template,
|
||||
endpoint_type,
|
||||
@@ -2036,7 +2036,7 @@ def test_blocked_secret_list(token_client, organizer, event):
|
||||
assert [res] == resp.data['results']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_queries):
|
||||
# order detail
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/?pdf_data=true'.format(
|
||||
|
||||
@@ -732,7 +732,7 @@ def test_five_tickets_one_free(event):
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.parametrize("itemcount", [3, 10, 50])
|
||||
def test_query_count_many_items(event, itemcount):
|
||||
setup_items(event, 'Tickets', 'both', 'discounts',
|
||||
@@ -784,7 +784,7 @@ def test_query_count_many_items(event, itemcount):
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.parametrize("catcount", [1, 10, 50])
|
||||
def test_query_count_many_categories_and_discounts(event, catcount):
|
||||
for n in range(1, catcount + 1):
|
||||
@@ -838,7 +838,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.parametrize("catcount", [2, 10, 50])
|
||||
def test_query_count_many_cartpos(event, catcount):
|
||||
for n in range(1, catcount + 1):
|
||||
|
||||
@@ -210,7 +210,7 @@ def test_validate_membership_required(event, customer, membership, requiring_tic
|
||||
assert "requires an active" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_validate_membership_ensure_locking(event, customer, membership, requiring_ticket, membership_type, django_assert_num_queries):
|
||||
with django_assert_num_queries(4) as captured:
|
||||
validate_memberships_in_order(
|
||||
|
||||
@@ -28,6 +28,7 @@ from django.test import override_settings
|
||||
from django.utils import translation
|
||||
from django_scopes import scopes_disabled
|
||||
from fakeredis import FakeRedisConnection
|
||||
from hierarkey.proxy import dirty_cache_keys
|
||||
from xdist.dsession import DSession
|
||||
|
||||
from pretix.testutils.mock import get_redis_connection
|
||||
@@ -82,6 +83,11 @@ def reset_locale():
|
||||
translation.activate("en")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_hierarkey_cache_state():
|
||||
dirty_cache_keys.set(set())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fakeredis_client(monkeypatch):
|
||||
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
|
||||
|
||||
@@ -90,6 +90,7 @@ event_urls = [
|
||||
"delete/",
|
||||
"dangerzone/",
|
||||
"cancel/",
|
||||
"quickstart/",
|
||||
"settings/",
|
||||
"settings/plugins",
|
||||
"settings/payment",
|
||||
@@ -311,6 +312,7 @@ event_permission_urls = [
|
||||
("event.settings.general:write", "live/", 200, HTTP_GET),
|
||||
("event.settings.general:write", "delete/", 200, HTTP_GET),
|
||||
("event.settings.general:write", "dangerzone/", 200, HTTP_GET),
|
||||
("event.settings.general:write", "quickstart/", 200, HTTP_GET),
|
||||
("event.settings.general:write", "settings/", 200, HTTP_GET),
|
||||
# ("event.settings.payment:write", "settings/payment", 200, HTTP_GET), GET allowed also with other permissions
|
||||
("event.settings.payment:write", "settings/payment", 200, HTTP_POST),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_allowed_json():
|
||||
assert json.loads('{"float":1.5,"int":161,"arr":[]}') == {"float": 1.5, "int": 161, "arr": []}
|
||||
|
||||
|
||||
def test_disallowed_json_float_consts():
|
||||
with pytest.raises(KeyError):
|
||||
json.loads("Infinity")
|
||||
with pytest.raises(KeyError):
|
||||
json.loads("-Infinity")
|
||||
with pytest.raises(KeyError):
|
||||
json.loads("NaN")
|
||||
with pytest.raises(KeyError):
|
||||
json.loads("[123, NaN, Infinity, -Infinity]")
|
||||
@@ -5959,3 +5959,14 @@ class CustomerCheckoutTestCase(BaseCheckoutTestCase, TestCase):
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert 'Gift card' in response.content.decode()
|
||||
assert '(1 available)' in response.content.decode()
|
||||
|
||||
def test_validation_bypass_error_async_id_is_fixed(self):
|
||||
with scopes_disabled():
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=0, listed_price=0, price_after_voucher=0, expires=now() + timedelta(minutes=10)
|
||||
)
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/?async_id=1' % (self.orga.slug, self.event.slug), follow=False)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
@@ -721,9 +721,21 @@ def _cross_domain_login(env, client, client2, org_alt=False):
|
||||
else:
|
||||
KnownDomain.objects.create(domainname='event.test', organizer=env[0], event=env[1])
|
||||
|
||||
# Log in on org domain
|
||||
# Start session on event domain
|
||||
path = '/conf/' if org_alt else '/'
|
||||
r = client.post(f'/account/login?next=https://event.test{path}redeem&request_cross_domain_customer_auth=true', {
|
||||
r = client2.post(f'{path}account/loginstart', {
|
||||
'next': f'https://event.test{path}redeem',
|
||||
}, HTTP_HOST='event.test')
|
||||
assert r.status_code == 302
|
||||
u = urlparse(r.headers['Location'])
|
||||
assert u.netloc == 'org.test'
|
||||
assert u.path == '/account/login'
|
||||
assert 'request_cross_domain_customer_auth=' in u.query
|
||||
assert 'request_cross_domain_customer_auth_nonce=' in u.query
|
||||
assert 'next=' in u.query
|
||||
|
||||
# Log in on org domain
|
||||
r = client.post(f'{u.path}?{u.query}', {
|
||||
'email': 'john@example.org',
|
||||
'password': 'foo',
|
||||
}, HTTP_HOST='org.test')
|
||||
@@ -734,6 +746,7 @@ def _cross_domain_login(env, client, client2, org_alt=False):
|
||||
assert u.path == path + 'redeem'
|
||||
q = parse_qs(u.query)
|
||||
assert 'cross_domain_customer_auth' in q
|
||||
assert 'cross_domain_customer_auth_nonce' in q
|
||||
|
||||
# Take session over to event domain
|
||||
r = client2.get(f'{path}?{u.query}', HTTP_HOST='event.test')
|
||||
@@ -745,7 +758,7 @@ def _cross_domain_login(env, client, client2, org_alt=False):
|
||||
def test_cross_domain_login(env, client, client2):
|
||||
_cross_domain_login(env, client, client2)
|
||||
|
||||
# Logged in on evnet domain
|
||||
# Logged in on event domain
|
||||
r = client.get('/', HTTP_HOST='event.test')
|
||||
assert r.status_code == 200
|
||||
assert b'john@example.org' in r.content
|
||||
@@ -896,7 +909,14 @@ def test_cross_domain_login_with_sso(env, client, client2, provider):
|
||||
},
|
||||
)
|
||||
|
||||
url = f'/account/login/{provider.pk}/?next=https://event.test/redeem&request_cross_domain_customer_auth=true'
|
||||
r = client2.post('/account/loginstart', {"next": "https://event.test/redeem"}, follow=False, HTTP_HOST='event.test')
|
||||
assert r.status_code == 302
|
||||
assert "/account/login" in r['Location']
|
||||
|
||||
u = urlparse(r.headers['Location'])
|
||||
nonce = parse_qs(u.query)['request_cross_domain_customer_auth_nonce'][0]
|
||||
url = (f'/account/login/{provider.pk}/?next=https://event.test/redeem&request_cross_domain_customer_auth=true&'
|
||||
f'request_cross_domain_customer_auth_nonce={nonce}')
|
||||
r = client.get(url, follow=False, HTTP_HOST='org.test')
|
||||
assert r.status_code == 302
|
||||
assert "/authorize" in r['Location']
|
||||
@@ -910,6 +930,7 @@ def test_cross_domain_login_with_sso(env, client, client2, provider):
|
||||
assert u.path == '/redeem'
|
||||
q = parse_qs(u.query)
|
||||
assert 'cross_domain_customer_auth' in q
|
||||
assert 'cross_domain_customer_auth_nonce' in q
|
||||
|
||||
# Take session over to event domain
|
||||
r = client2.get(f'/?{u.query}', HTTP_HOST='event.test')
|
||||
|
||||
Reference in New Issue
Block a user