mirror of
https://github.com/pretix/pretix.git
synced 2026-04-26 23:52:35 +00:00
Compare commits
7 Commits
datasync-l
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae8ba2528a | ||
|
|
2dbb7ebd2d | ||
|
|
5dac696cb8 | ||
|
|
474ca35616 | ||
|
|
ff351f2856 | ||
|
|
5d87f9a26f | ||
|
|
4b5651862c |
@@ -19,4 +19,4 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# 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/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "2025.10.0"
|
__version__ = "2025.10.2"
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ class ExportersMixin:
|
|||||||
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||||
def download(self, *args, **kwargs):
|
def download(self, *args, **kwargs):
|
||||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||||
|
if not cf.allowed_for_session(self.request, "exporters-api"):
|
||||||
|
return Response(
|
||||||
|
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||||
|
status=status.HTTP_410_GONE
|
||||||
|
)
|
||||||
if cf.file:
|
if cf.file:
|
||||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||||
@@ -109,7 +114,8 @@ class ExportersMixin:
|
|||||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
cf = CachedFile(web_download=False)
|
cf = CachedFile(web_download=True)
|
||||||
|
cf.bind_to_session(self.request, "exporters-api")
|
||||||
cf.date = now()
|
cf.date = now()
|
||||||
cf.expires = now() + timedelta(hours=24)
|
cf.expires = now() + timedelta(hours=24)
|
||||||
cf.save()
|
cf.save()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from pretix.base.templatetags.rich_text import (
|
|||||||
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
|
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
|
||||||
markdown_compile_email, truelink_callback,
|
markdown_compile_email, truelink_callback,
|
||||||
)
|
)
|
||||||
from pretix.helpers.format import SafeFormatter, format_map
|
from pretix.helpers.format import FormattedString, SafeFormatter, format_map
|
||||||
|
|
||||||
from pretix.base.services.placeholders import ( # noqa
|
from pretix.base.services.placeholders import ( # noqa
|
||||||
get_available_placeholders, PlaceholderContext
|
get_available_placeholders, PlaceholderContext
|
||||||
@@ -141,6 +141,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
return markdown_compile_email(plaintext, context=context)
|
return markdown_compile_email(plaintext, context=context)
|
||||||
|
|
||||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
||||||
|
apply_format_map = not isinstance(plain_body, FormattedString)
|
||||||
body_md = self.compile_markdown(plain_body, context)
|
body_md = self.compile_markdown(plain_body, context)
|
||||||
if context:
|
if context:
|
||||||
linker = bleach.Linker(
|
linker = bleach.Linker(
|
||||||
@@ -149,12 +150,13 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||||
parse_email=True
|
parse_email=True
|
||||||
)
|
)
|
||||||
body_md = format_map(
|
if apply_format_map:
|
||||||
body_md,
|
body_md = format_map(
|
||||||
context=context,
|
body_md,
|
||||||
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
context=context,
|
||||||
linkifier=linker
|
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
||||||
)
|
linkifier=linker
|
||||||
|
)
|
||||||
htmlctx = {
|
htmlctx = {
|
||||||
'site': settings.PRETIX_INSTANCE_NAME,
|
'site': settings.PRETIX_INSTANCE_NAME,
|
||||||
'site_url': settings.SITE_URL,
|
'site_url': settings.SITE_URL,
|
||||||
|
|||||||
@@ -58,6 +58,37 @@ class CachedFile(models.Model):
|
|||||||
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
||||||
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
||||||
|
|
||||||
|
def session_key_for_request(self, request, salt=None):
|
||||||
|
from ...api.models import OAuthAccessToken, OAuthApplication
|
||||||
|
from .devices import Device
|
||||||
|
from .organizer import TeamAPIToken
|
||||||
|
|
||||||
|
if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken):
|
||||||
|
k = f'app:{request.auth.application.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication):
|
||||||
|
k = f'app:{request.auth.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken):
|
||||||
|
k = f'token:{request.auth.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, Device):
|
||||||
|
k = f'device:{request.auth.pk}'
|
||||||
|
elif request.session.session_key:
|
||||||
|
k = request.session.session_key
|
||||||
|
else:
|
||||||
|
raise ValueError("No auth method found to bind to")
|
||||||
|
|
||||||
|
if salt:
|
||||||
|
k = f"{k}!{salt}"
|
||||||
|
return k
|
||||||
|
|
||||||
|
def allowed_for_session(self, request, salt=None):
|
||||||
|
return (
|
||||||
|
not self.session_key or
|
||||||
|
self.session_key_for_request(request, salt) == self.session_key
|
||||||
|
)
|
||||||
|
|
||||||
|
def bind_to_session(self, request, salt=None):
|
||||||
|
self.session_key = self.session_key_for_request(request, salt)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=CachedFile)
|
@receiver(post_delete, sender=CachedFile)
|
||||||
def cached_file_delete(sender, instance, **kwargs):
|
def cached_file_delete(sender, instance, **kwargs):
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ from pretix.base.timemachine import time_machine_now
|
|||||||
|
|
||||||
from ...helpers import OF_SELF
|
from ...helpers import OF_SELF
|
||||||
from ...helpers.countries import CachedCountries, FastCountryField
|
from ...helpers.countries import CachedCountries, FastCountryField
|
||||||
from ...helpers.format import format_map
|
from ...helpers.format import FormattedString, format_map
|
||||||
from ...helpers.names import build_name
|
from ...helpers.names import build_name
|
||||||
from ...testutils.middleware import debugflags_var
|
from ...testutils.middleware import debugflags_var
|
||||||
from ._transactions import (
|
from ._transactions import (
|
||||||
@@ -1181,7 +1181,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
email_content = render_mail(template, context)
|
email_content = render_mail(template, context)
|
||||||
subject = format_map(subject, context)
|
if not isinstance(subject, FormattedString):
|
||||||
|
subject = format_map(subject, context)
|
||||||
mail(
|
mail(
|
||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.locale, self, headers=headers, sender=sender,
|
self.event, self.locale, self, headers=headers, sender=sender,
|
||||||
@@ -2926,7 +2927,8 @@ class OrderPosition(AbstractPosition):
|
|||||||
recipient = self.attendee_email
|
recipient = self.attendee_email
|
||||||
try:
|
try:
|
||||||
email_content = render_mail(template, context)
|
email_content = render_mail(template, context)
|
||||||
subject = format_map(subject, context)
|
if not isinstance(subject, FormattedString):
|
||||||
|
subject = format_map(subject, context)
|
||||||
mail(
|
mail(
|
||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ from pretix.base.services.tasks import TransactionAwareTask
|
|||||||
from pretix.base.services.tickets import get_tickets_for_order
|
from pretix.base.services.tickets import get_tickets_for_order
|
||||||
from pretix.base.signals import email_filter, global_email_filter
|
from pretix.base.signals import email_filter, global_email_filter
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers.format import SafeFormatter, format_map
|
from pretix.helpers.format import (
|
||||||
|
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
|
||||||
|
)
|
||||||
from pretix.helpers.hierarkey import clean_filename
|
from pretix.helpers.hierarkey import clean_filename
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
from pretix.presale.ical import get_private_icals
|
from pretix.presale.ical import get_private_icals
|
||||||
@@ -200,6 +202,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
if email == INVALID_ADDRESS:
|
if email == INVALID_ADDRESS:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if isinstance(template, FormattedString):
|
||||||
|
raise TypeError("Cannot pass an already formatted body template")
|
||||||
|
|
||||||
if no_order_links and not plain_text_only:
|
if no_order_links and not plain_text_only:
|
||||||
raise ValueError('If you set no_order_links, you also need to set plain_text_only.')
|
raise ValueError('If you set no_order_links, you also need to set plain_text_only.')
|
||||||
|
|
||||||
@@ -222,8 +227,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
'invoice_company': ''
|
'invoice_company': ''
|
||||||
})
|
})
|
||||||
renderer = ClassicMailRenderer(None, organizer)
|
renderer = ClassicMailRenderer(None, organizer)
|
||||||
body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN)
|
content_plain = render_mail(template, context, placeholder_mode=None)
|
||||||
subject = str(subject).format_map(TolerantDict(context))
|
if not isinstance(subject, FormattedString):
|
||||||
|
subject = format_map(subject, context)
|
||||||
sender = (
|
sender = (
|
||||||
sender or
|
sender or
|
||||||
(event.settings.get('mail_from') if event else None) or
|
(event.settings.get('mail_from') if event else None) or
|
||||||
@@ -255,6 +261,10 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
else:
|
else:
|
||||||
timezone = ZoneInfo(settings.TIME_ZONE)
|
timezone = ZoneInfo(settings.TIME_ZONE)
|
||||||
|
|
||||||
|
if not isinstance(content_plain, FormattedString):
|
||||||
|
body_plain = format_map(content_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
|
||||||
|
else:
|
||||||
|
body_plain = content_plain
|
||||||
if settings_holder:
|
if settings_holder:
|
||||||
if settings_holder.settings.mail_bcc:
|
if settings_holder.settings.mail_bcc:
|
||||||
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
||||||
@@ -270,7 +280,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
|
|
||||||
signature = str(settings_holder.settings.get('mail_text_signature'))
|
signature = str(settings_holder.settings.get('mail_text_signature'))
|
||||||
if signature:
|
if signature:
|
||||||
signature = signature.format(event=event.name if event else '')
|
signature = format_map(signature, {"event": event.name if event else ''})
|
||||||
body_plain += signature
|
body_plain += signature
|
||||||
body_plain += "\r\n\r\n-- \r\n"
|
body_plain += "\r\n\r\n-- \r\n"
|
||||||
|
|
||||||
@@ -288,7 +298,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
body_plain += _(
|
body_plain += _(
|
||||||
"You can view your order details at the following URL:\n{orderurl}."
|
"You can view your order details at the following URL:\n{orderurl}."
|
||||||
).replace("\n", "\r\n").format(
|
).replace("\n", "\r\n").format(
|
||||||
event=event.name, orderurl=build_absolute_uri(
|
orderurl=build_absolute_uri(
|
||||||
order.event, 'presale:event.order.position', kwargs={
|
order.event, 'presale:event.order.position', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': position.web_secret,
|
'secret': position.web_secret,
|
||||||
@@ -304,7 +314,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
body_plain += _(
|
body_plain += _(
|
||||||
"You can view your order details at the following URL:\n{orderurl}."
|
"You can view your order details at the following URL:\n{orderurl}."
|
||||||
).replace("\n", "\r\n").format(
|
).replace("\n", "\r\n").format(
|
||||||
event=event.name, orderurl=build_absolute_uri(
|
orderurl=build_absolute_uri(
|
||||||
order.event, 'presale:event.order.open', kwargs={
|
order.event, 'presale:event.order.open', kwargs={
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret,
|
'secret': order.secret,
|
||||||
@@ -316,7 +326,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
|
|
||||||
with override(timezone):
|
with override(timezone):
|
||||||
try:
|
try:
|
||||||
content_plain = render_mail(template, context, placeholder_mode=None)
|
|
||||||
if plain_text_only:
|
if plain_text_only:
|
||||||
body_html = None
|
body_html = None
|
||||||
elif 'context' in inspect.signature(renderer.render).parameters:
|
elif 'context' in inspect.signature(renderer.render).parameters:
|
||||||
@@ -337,8 +346,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
logger.exception('Could not render HTML body')
|
logger.exception('Could not render HTML body')
|
||||||
body_html = None
|
body_html = None
|
||||||
|
|
||||||
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
|
|
||||||
|
|
||||||
send_task = mail_send_task.si(
|
send_task = mail_send_task.si(
|
||||||
to=[email] if isinstance(email, str) else list(email),
|
to=[email] if isinstance(email, str) else list(email),
|
||||||
cc=cc,
|
cc=cc,
|
||||||
@@ -759,7 +766,12 @@ def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_P
|
|||||||
body = format_map(body, context, mode=placeholder_mode)
|
body = format_map(body, context, mode=placeholder_mode)
|
||||||
else:
|
else:
|
||||||
tpl = get_template(template)
|
tpl = get_template(template)
|
||||||
body = tpl.render(context)
|
context = {
|
||||||
|
# Known bug, should behave differently for plain and HTML but we'll fix after security release
|
||||||
|
k: v.html if isinstance(v, PlainHtmlAlternativeString) else v
|
||||||
|
for k, v in context.items()
|
||||||
|
}
|
||||||
|
body = FormattedString(tpl.render(context))
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ class DownloadView(TemplateView):
|
|||||||
def object(self) -> CachedFile:
|
def object(self) -> CachedFile:
|
||||||
try:
|
try:
|
||||||
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
|
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
|
||||||
if o.session_key:
|
if not o.allowed_for_session(self.request):
|
||||||
if o.session_key != self.request.session.session_key:
|
raise Http404()
|
||||||
raise Http404()
|
|
||||||
return o
|
return o
|
||||||
except (ValueError, ValidationError): # Invalid URLs
|
except (ValueError, ValidationError): # Invalid URLs
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -85,6 +86,7 @@ class BaseImportView(TemplateView):
|
|||||||
filename='import.csv',
|
filename='import.csv',
|
||||||
type='text/csv',
|
type='text/csv',
|
||||||
)
|
)
|
||||||
|
cf.bind_to_session(request, "modelimport")
|
||||||
cf.file.save('import.csv', request.FILES['file'])
|
cf.file.save('import.csv', request.FILES['file'])
|
||||||
|
|
||||||
if self.request.POST.get("charset") in ENCODINGS:
|
if self.request.POST.get("charset") in ENCODINGS:
|
||||||
@@ -137,7 +139,10 @@ class BaseProcessView(AsyncAction, FormView):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def file(self):
|
def file(self):
|
||||||
return get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
|
cf = get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
|
||||||
|
if not cf.allowed_for_session(self.request, "modelimport"):
|
||||||
|
raise Http404()
|
||||||
|
return cf
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def parsed(self):
|
def parsed(self):
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
|
|||||||
cf = None
|
cf = None
|
||||||
if request.POST.get("background", "").strip():
|
if request.POST.get("background", "").strip():
|
||||||
try:
|
try:
|
||||||
cf = CachedFile.objects.get(id=request.POST.get("background"))
|
cf = CachedFile.objects.get(id=request.POST.get("background"), web_download=True)
|
||||||
except CachedFile.DoesNotExist:
|
except CachedFile.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ from collections import OrderedDict
|
|||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.http import Http404
|
||||||
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import get_language, gettext_lazy as _
|
from django.utils.translation import get_language, gettext_lazy as _
|
||||||
@@ -94,6 +95,8 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
|
|||||||
cf = CachedFile.objects.get(pk=kwargs['file'])
|
cf = CachedFile.objects.get(pk=kwargs['file'])
|
||||||
except CachedFile.DoesNotExist:
|
except CachedFile.DoesNotExist:
|
||||||
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
|
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
|
||||||
|
if not cf.allowed_for_session(self.request):
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
with ZipFile(cf.file.file, 'r') as zipfile:
|
with ZipFile(cf.file.file, 'r') as zipfile:
|
||||||
indexdata = json.loads(zipfile.read('index.json').decode())
|
indexdata = json.loads(zipfile.read('index.json').decode())
|
||||||
@@ -111,7 +114,7 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
|
|||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
ctx['shredders'] = self.shredders
|
ctx['shredders'] = self.shredders
|
||||||
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
|
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
|
||||||
ctx['file'] = get_object_or_404(CachedFile, pk=kwargs.get("file"))
|
ctx['file'] = cf
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from string import Formatter
|
from string import Formatter
|
||||||
|
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -37,6 +38,17 @@ class PlainHtmlAlternativeString:
|
|||||||
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
|
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
|
||||||
|
|
||||||
|
|
||||||
|
class FormattedString(str):
|
||||||
|
"""
|
||||||
|
A str subclass that has been specifically marked as "already formatted" for email rendering
|
||||||
|
purposes to avoid duplicate formatting.
|
||||||
|
"""
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class SafeFormatter(Formatter):
|
class SafeFormatter(Formatter):
|
||||||
"""
|
"""
|
||||||
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
|
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
|
||||||
@@ -77,8 +89,19 @@ class SafeFormatter(Formatter):
|
|||||||
# Ignore format_spec
|
# Ignore format_spec
|
||||||
return super().format_field(self._prepare_value(value), '')
|
return super().format_field(self._prepare_value(value), '')
|
||||||
|
|
||||||
|
def convert_field(self, value, conversion):
|
||||||
|
# Ignore any conversions
|
||||||
|
if conversion is None:
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None):
|
|
||||||
|
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None) -> FormattedString:
|
||||||
|
if isinstance(template, FormattedString):
|
||||||
|
raise SuspiciousOperation("Calling format_map() on an already formatted string is likely unsafe.")
|
||||||
if not isinstance(template, str):
|
if not isinstance(template, str):
|
||||||
template = str(template)
|
template = str(template)
|
||||||
return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
|
return FormattedString(
|
||||||
|
SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,20 +32,23 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import mail as djmail
|
from django.core import mail as djmail
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_scopes import scope
|
from django_scopes import scope, scopes_disabled
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
from pretix.base.email import get_email_context
|
from pretix.base.email import get_email_context
|
||||||
from pretix.base.models import Event, Organizer, User
|
from pretix.base.models import Event, InvoiceAddress, Order, Organizer, User
|
||||||
from pretix.base.services.mail import mail
|
from pretix.base.services.mail import mail
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +70,45 @@ def env():
|
|||||||
yield event, user, o
|
yield event, user, o
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
@scopes_disabled()
|
||||||
|
def item(env):
|
||||||
|
return env[0].items.create(name="Budget Ticket", default_price=23)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
@scopes_disabled()
|
||||||
|
def order(env, item):
|
||||||
|
event, _, _ = env
|
||||||
|
o = Order.objects.create(
|
||||||
|
code="FOO",
|
||||||
|
event=event,
|
||||||
|
email="dummy@dummy.test",
|
||||||
|
status=Order.STATUS_PENDING,
|
||||||
|
secret="k24fiuwvu8kxz3y1",
|
||||||
|
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||||
|
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.UTC),
|
||||||
|
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.UTC),
|
||||||
|
total=23,
|
||||||
|
locale="en",
|
||||||
|
)
|
||||||
|
o.positions.create(
|
||||||
|
order=o,
|
||||||
|
item=item,
|
||||||
|
variation=None,
|
||||||
|
price=Decimal("23"),
|
||||||
|
attendee_email="peter@example.org",
|
||||||
|
attendee_name_parts={"given_name": "Peter", "family_name": "Miller"},
|
||||||
|
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||||
|
pseudonymization_id="ABCDEFGHKL",
|
||||||
|
)
|
||||||
|
InvoiceAddress.objects.create(
|
||||||
|
order=o,
|
||||||
|
name_parts={"given_name": "Peter", "family_name": "Miller"},
|
||||||
|
)
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_send_mail_with_prefix(env):
|
def test_send_mail_with_prefix(env):
|
||||||
djmail.outbox = []
|
djmail.outbox = []
|
||||||
@@ -188,7 +230,7 @@ def _extract_html(mail):
|
|||||||
def test_placeholder_html_rendering_from_template(env):
|
def test_placeholder_html_rendering_from_template(env):
|
||||||
djmail.outbox = []
|
djmail.outbox = []
|
||||||
event, user, organizer = env
|
event, user, organizer = env
|
||||||
event.name = "<strong>event & co. kg</strong>"
|
event.name = "<strong>event & co. kg</strong> {currency}"
|
||||||
event.save()
|
event.save()
|
||||||
mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context(
|
mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context(
|
||||||
event=event,
|
event=event,
|
||||||
@@ -197,25 +239,26 @@ def test_placeholder_html_rendering_from_template(env):
|
|||||||
|
|
||||||
assert len(djmail.outbox) == 1
|
assert len(djmail.outbox) == 1
|
||||||
assert djmail.outbox[0].to == [user.email]
|
assert djmail.outbox[0].to == [user.email]
|
||||||
assert 'Event name: <strong>event & co. kg</strong>' in djmail.outbox[0].body
|
# Known bug for now: These should not have HTML for the plain body, but we'll fix this safter the security release
|
||||||
assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
|
assert escape('Event name: <strong>event & co. kg</strong> {currency}') in djmail.outbox[0].body
|
||||||
assert '**Meta**: *Beep*' in djmail.outbox[0].body
|
assert '<strong>IBAN</strong>: 123<br>\n<strong>BIC</strong>: 456' in djmail.outbox[0].body
|
||||||
assert 'Event website: [<strong>event & co. kg</strong>](https://example.org/dummy)' in djmail.outbox[0].body
|
assert '**Meta**: <em>Beep</em>' in djmail.outbox[0].body
|
||||||
assert 'Other website: [<strong>event & co. kg</strong>](https://example.com)' in djmail.outbox[0].body
|
assert escape('Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)') in djmail.outbox[0].body
|
||||||
assert '<' not in djmail.outbox[0].body
|
# todo: assert '<' not in djmail.outbox[0].body
|
||||||
assert '&' not in djmail.outbox[0].body
|
# todo: assert '&' not in djmail.outbox[0].body
|
||||||
|
assert 'Unevaluated placeholder: {currency}' in djmail.outbox[0].body
|
||||||
|
assert 'EUR' not in djmail.outbox[0].body
|
||||||
html = _extract_html(djmail.outbox[0])
|
html = _extract_html(djmail.outbox[0])
|
||||||
|
|
||||||
assert '<strong>event' not in html
|
assert '<strong>event' not in html
|
||||||
assert 'Event name: <strong>event & co. kg</strong>' in html
|
assert 'Event name: <strong>event & co. kg</strong> {currency}' in html
|
||||||
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
|
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
|
||||||
assert '<strong>Meta</strong>: <em>Beep</em>' in html
|
assert '<strong>Meta</strong>: <em>Beep</em>' in html
|
||||||
|
assert 'Unevaluated placeholder: {currency}' in html
|
||||||
|
assert 'EUR' not in html
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">'
|
||||||
html
|
r'<strong>event & co. kg</strong> {currency}</a>',
|
||||||
)
|
|
||||||
assert re.search(
|
|
||||||
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
|
||||||
html
|
html
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -231,7 +274,7 @@ def test_placeholder_html_rendering_from_string(env):
|
|||||||
})
|
})
|
||||||
djmail.outbox = []
|
djmail.outbox = []
|
||||||
event, user, organizer = env
|
event, user, organizer = env
|
||||||
event.name = "<strong>event & co. kg</strong>"
|
event.name = "<strong>event & co. kg</strong> {currency}"
|
||||||
event.save()
|
event.save()
|
||||||
ctx = get_email_context(
|
ctx = get_email_context(
|
||||||
event=event,
|
event=event,
|
||||||
@@ -242,9 +285,9 @@ def test_placeholder_html_rendering_from_string(env):
|
|||||||
|
|
||||||
assert len(djmail.outbox) == 1
|
assert len(djmail.outbox) == 1
|
||||||
assert djmail.outbox[0].to == [user.email]
|
assert djmail.outbox[0].to == [user.email]
|
||||||
assert 'Event name: <strong>event & co. kg</strong>' in djmail.outbox[0].body
|
assert 'Event name: <strong>event & co. kg</strong> {currency}' in djmail.outbox[0].body
|
||||||
assert 'Event website: [<strong>event & co. kg</strong>](https://example.org/dummy)' in djmail.outbox[0].body
|
assert 'Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)' in djmail.outbox[0].body
|
||||||
assert 'Other website: [<strong>event & co. kg</strong>](https://example.com)' in djmail.outbox[0].body
|
assert 'Other website: [<strong>event & co. kg</strong> {currency}](https://example.com)' in djmail.outbox[0].body
|
||||||
assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
|
assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
|
||||||
assert '**Meta**: *Beep*' in djmail.outbox[0].body
|
assert '**Meta**: *Beep*' in djmail.outbox[0].body
|
||||||
assert 'URL: https://google.com' in djmail.outbox[0].body
|
assert 'URL: https://google.com' in djmail.outbox[0].body
|
||||||
@@ -257,11 +300,13 @@ def test_placeholder_html_rendering_from_string(env):
|
|||||||
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
|
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
|
||||||
assert '<strong>Meta</strong>: <em>Beep</em>' in html
|
assert '<strong>Meta</strong>: <em>Beep</em>' in html
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">'
|
||||||
|
r'<strong>event & co. kg</strong> {currency}</a>',
|
||||||
html
|
html
|
||||||
)
|
)
|
||||||
assert re.search(
|
assert re.search(
|
||||||
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank">'
|
||||||
|
r'<strong>event & co. kg</strong> {currency}</a>',
|
||||||
html
|
html
|
||||||
)
|
)
|
||||||
assert re.search(
|
assert re.search(
|
||||||
@@ -272,3 +317,141 @@ def test_placeholder_html_rendering_from_string(env):
|
|||||||
r'URL with text: <a href="https://google.com" rel="noopener" style="[^"]+" target="_blank">Test</a>',
|
r'URL with text: <a href="https://google.com" rel="noopener" style="[^"]+" target="_blank">Test</a>',
|
||||||
html
|
html
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_nested_placeholder_inclusion_full_process(env, order):
|
||||||
|
# Test that it is not possible to sneak in a placeholder like {url_cancel} inside a user-controlled
|
||||||
|
# placeholder value like {invoice_company}
|
||||||
|
event, user, organizer = env
|
||||||
|
position = order.positions.get()
|
||||||
|
order.invoice_address.company = "{url_cancel} Corp"
|
||||||
|
order.invoice_address.save()
|
||||||
|
event.settings.mail_text_resend_link = LazyI18nString({"en": "Ticket for {invoice_company}"})
|
||||||
|
event.settings.mail_subject_resend_link_attendee = LazyI18nString({"en": "Ticket for {invoice_company}"})
|
||||||
|
|
||||||
|
djmail.outbox = []
|
||||||
|
position.resend_link()
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
assert djmail.outbox[0].to == [position.attendee_email]
|
||||||
|
assert "Ticket for {url_cancel} Corp" == djmail.outbox[0].subject
|
||||||
|
assert "/cancel" not in djmail.outbox[0].body
|
||||||
|
assert "/order" not in djmail.outbox[0].body
|
||||||
|
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
|
||||||
|
for part in (html, plain):
|
||||||
|
assert "Ticket for {url_cancel} Corp" in part
|
||||||
|
assert "/order/" not in part
|
||||||
|
assert "/cancel" not in part
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_nested_placeholder_inclusion_mail_service(env):
|
||||||
|
# test that it is not possible to have placeholders within the values of placeholders when
|
||||||
|
# the mail() function is called directly
|
||||||
|
template = LazyI18nString("Event name: {event}")
|
||||||
|
djmail.outbox = []
|
||||||
|
event, user, organizer = env
|
||||||
|
event.name = "event & {currency} co. kg"
|
||||||
|
event.slug = "event-co-ag-slug"
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
mail(
|
||||||
|
"dummy@dummy.dummy",
|
||||||
|
"{event} Test subject",
|
||||||
|
template,
|
||||||
|
get_email_context(
|
||||||
|
event=event,
|
||||||
|
payment_info="**IBAN**: 123 \n**BIC**: 456 {event}",
|
||||||
|
),
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
assert djmail.outbox[0].to == [user.email]
|
||||||
|
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
|
||||||
|
for part in (html, plain, djmail.outbox[0].subject):
|
||||||
|
assert "event & {currency} co. kg" in part or "event & {currency} co. kg" in part
|
||||||
|
assert "EUR" not in part
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize("tpl", [
|
||||||
|
"Event: {event.__class__}",
|
||||||
|
"Event: {{event.__class__}}",
|
||||||
|
"Event: {{{event.__class__}}}",
|
||||||
|
])
|
||||||
|
def test_variable_inclusion_from_string_full_process(env, tpl, order):
|
||||||
|
# Test that it is not possible to use placeholders that leak system information in templates
|
||||||
|
# when run through system processes
|
||||||
|
event, user, organizer = env
|
||||||
|
event.name = "event & co. kg"
|
||||||
|
event.save()
|
||||||
|
position = order.positions.get()
|
||||||
|
event.settings.mail_text_resend_link = LazyI18nString({"en": tpl})
|
||||||
|
event.settings.mail_subject_resend_link_attendee = LazyI18nString({"en": tpl})
|
||||||
|
|
||||||
|
position.resend_link()
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
|
||||||
|
for part in (html, plain, djmail.outbox[0].subject):
|
||||||
|
assert "{event.__class__}" in part
|
||||||
|
assert "LazyI18nString" not in part
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize("tpl", [
|
||||||
|
"Event: {event.__class__}",
|
||||||
|
"Event: {{event.__class__}}",
|
||||||
|
"Event: {{{event.__class__}}}",
|
||||||
|
])
|
||||||
|
def test_variable_inclusion_from_string_mail_service(env, tpl):
|
||||||
|
# Test that it is not possible to use placeholders that leak system information in templates
|
||||||
|
# when run through mail() directly
|
||||||
|
event, user, organizer = env
|
||||||
|
event.name = "event & co. kg"
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
djmail.outbox = []
|
||||||
|
mail(
|
||||||
|
"dummy@dummy.dummy",
|
||||||
|
tpl,
|
||||||
|
LazyI18nString(tpl),
|
||||||
|
get_email_context(
|
||||||
|
event=event,
|
||||||
|
payment_info="**IBAN**: 123 \n**BIC**: 456\n" + tpl,
|
||||||
|
),
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
|
||||||
|
for part in (html, plain, djmail.outbox[0].subject):
|
||||||
|
assert "{event.__class__}" in part
|
||||||
|
assert "LazyI18nString" not in part
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_escaped_braces_mail_services(env):
|
||||||
|
# Test that braces can be escaped by doubling
|
||||||
|
template = LazyI18nString("Event name: -{{currency}}-")
|
||||||
|
djmail.outbox = []
|
||||||
|
event, user, organizer = env
|
||||||
|
event.name = "event & co. kg"
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
mail(
|
||||||
|
"dummy@dummy.dummy",
|
||||||
|
"-{{currency}}- Test subject",
|
||||||
|
template,
|
||||||
|
get_email_context(
|
||||||
|
event=event,
|
||||||
|
payment_info="**IBAN**: 123 \n**BIC**: 456 {event}",
|
||||||
|
),
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
assert djmail.outbox[0].to == [user.email]
|
||||||
|
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
|
||||||
|
for part in (html, plain, djmail.outbox[0].subject):
|
||||||
|
assert "EUR" not in part
|
||||||
|
assert "-{currency}-" in part
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ def test_format_map():
|
|||||||
assert format_map("Foo {baz}", {"bar": 3}) == "Foo {baz}"
|
assert format_map("Foo {baz}", {"bar": 3}) == "Foo {baz}"
|
||||||
assert format_map("Foo {bar.__module__}", {"bar": 3}) == "Foo {bar.__module__}"
|
assert format_map("Foo {bar.__module__}", {"bar": 3}) == "Foo {bar.__module__}"
|
||||||
assert format_map("Foo {bar!s}", {"bar": 3}) == "Foo 3"
|
assert format_map("Foo {bar!s}", {"bar": 3}) == "Foo 3"
|
||||||
|
assert format_map("Foo {bar!r}", {"bar": '3'}) == "Foo 3"
|
||||||
|
assert format_map("Foo {bar!a}", {"bar": '3'}) == "Foo 3"
|
||||||
assert format_map("Foo {bar:<20}", {"bar": 3}) == "Foo 3"
|
assert format_map("Foo {bar:<20}", {"bar": 3}) == "Foo 3"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
This is a test file for sending mails.
|
This is a test file for sending mails.
|
||||||
Event name: {event}
|
Event name: {{ event }}
|
||||||
|
Unevaluated placeholder: {currency}
|
||||||
{% get_current_language as LANGUAGE_CODE %}
|
{% get_current_language as LANGUAGE_CODE %}
|
||||||
The language code used for rendering this email is {{ LANGUAGE_CODE }}.
|
The language code used for rendering this email is {{ LANGUAGE_CODE }}.
|
||||||
|
|
||||||
Payment info:
|
Payment info:
|
||||||
{payment_info}
|
{{ payment_info }}
|
||||||
|
|
||||||
**Meta**: {meta_Test}
|
**Meta**: {{ meta_Test }}
|
||||||
|
|
||||||
Event website: [{event}](https://example.org/{event_slug})
|
Event website: [{{event}}](https://example.org/{{event_slug}})
|
||||||
Other website: [{event}]({meta_Website})
|
|
||||||
Reference in New Issue
Block a user