diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index d05574c62..17d5dbda4 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -80,6 +80,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import order_gracefully_delete from ...helpers.countries import CachedCountries, FastCountryField +from ...helpers.format import format_map from ._transactions import ( _fail, _transactions_mark_order_clean, _transactions_mark_order_dirty, ) @@ -996,7 +997,7 @@ class Order(LockModel, LoggedModel): position and the attendee email will be used if available. """ from pretix.base.services.mail import ( - SendMailException, TolerantDict, mail, render_mail, + SendMailException, mail, render_mail, ) if not self.email and not (position and position.attendee_email): @@ -1012,7 +1013,7 @@ class Order(LockModel, LoggedModel): try: email_content = render_mail(template, context) - subject = str(subject).format_map(TolerantDict(context)) + subject = format_map(subject, context) mail( recipient, subject, template, context, self.event, self.locale, self, headers=headers, sender=sender, @@ -2414,7 +2415,7 @@ class OrderPosition(AbstractPosition): :param attach_ical: Attach relevant ICS files """ from pretix.base.services.mail import ( - SendMailException, TolerantDict, mail, render_mail, + SendMailException, mail, render_mail, ) if not self.attendee_email: @@ -2427,7 +2428,7 @@ class OrderPosition(AbstractPosition): recipient = self.attendee_email try: email_content = render_mail(template, context) - subject = str(subject).format_map(TolerantDict(context)) + subject = format_map(subject, context) mail( recipient, subject, template, context, self.event, self.order.locale, order=self.order, headers=headers, sender=sender, diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 68dbd1e1e..c6a9e035a 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -68,6 +68,7 @@ from pretix.base.signals import register_payment_providers from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import rich_text from pretix.helpers.countries import CachedCountries +from pretix.helpers.format import format_map from pretix.helpers.money import DecimalTextInput from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.views import get_cart, get_cart_total @@ -1122,12 +1123,12 @@ class ManualPayment(BasePaymentProvider): } def order_pending_mail_render(self, order, payment) -> str: - msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order, payment)) + msg = format_map(self.settings.get('email_instructions', as_type=LazyI18nString), self.format_map(order, payment)) return msg def payment_pending_render(self, request, payment) -> str: return rich_text( - str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order, payment)) + format_map(self.settings.get('pending_description', as_type=LazyI18nString), self.format_map(payment.order, payment)) ) diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py index 58d2527d3..2bad4e5b9 100644 --- a/src/pretix/base/services/cancelevent.py +++ b/src/pretix/base/services/cancelevent.py @@ -35,12 +35,13 @@ from pretix.base.models import ( SubEvent, User, WaitingListEntry, ) from pretix.base.services.locking import LockTimeoutException -from pretix.base.services.mail import SendMailException, TolerantDict, mail +from pretix.base.services.mail import SendMailException, mail from pretix.base.services.orders import ( OrderChangeManager, OrderError, _cancel_order, _try_auto_refund, ) from pretix.base.services.tasks import ProfiledEventTask from pretix.celery_app import app +from pretix.helpers.format import format_map logger = logging.getLogger(__name__) @@ -51,7 +52,7 @@ def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: Lazy try: mail( wle.email, - str(subject).format_map(TolerantDict(email_context)), + format_map(subject, email_context), message, email_context, wle.event, @@ -71,7 +72,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount, order=order, position_or_address=ia, event=order.event) - real_subject = str(subject).format_map(TolerantDict(email_context)) + real_subject = format_map(subject, email_context) try: order.send_mail( real_subject, message, email_context, @@ -86,7 +87,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s continue if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: - real_subject = str(subject).format_map(TolerantDict(email_context)) + real_subject = format_map(subject, email_context) email_context = get_email_context(event_or_subevent=p.subevent or order.event, event=order.event, refund_amount=refund_amount, diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index b509ee188..4bb61f46c 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -76,6 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask from pretix.base.services.tickets import get_tickets_for_order from pretix.base.signals import email_filter, global_email_filter from pretix.celery_app import app +from pretix.helpers.format import format_map from pretix.helpers.hierarkey import clean_filename from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.ical import get_private_icals @@ -85,6 +86,7 @@ INVALID_ADDRESS = 'invalid-pretix-mail-address' class TolerantDict(dict): + # kept for backwards compatibility with plugins def __missing__(self, key): return key @@ -109,7 +111,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La :param template: The filename of a template to be used. It will be rendered with the locale given in the locale argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and - ``context`` will be used as the argument to a Python ``.format_map()`` call on the template. + ``context`` will be used as the argument to a ``pretix.helpers.format.format_map(template, context)`` call on the template. :param context: The context for rendering the template (see ``template`` parameter) @@ -177,7 +179,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La }) renderer = ClassicMailRenderer(None, organizer) content_plain = body_plain = render_mail(template, context) - subject = str(subject).format_map(TolerantDict(context)) + subject = format_map(str(subject), context) sender = ( sender or (event.settings.get('mail_from') if event else None) or @@ -608,7 +610,7 @@ def render_mail(template, context): if isinstance(template, LazyI18nString): body = str(template) if context: - body = body.format_map(TolerantDict(context)) + body = format_map(body, context) else: tpl = get_template(template) body = tpl.render(context) diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 314dd5ac7..377e6b324 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -93,8 +93,8 @@ from ...base.i18n import language from ...base.models.items import ( Item, ItemCategory, ItemMetaProperty, Question, Quota, ) -from ...base.services.mail import TolerantDict from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList +from ...helpers.format import format_map from ..logdisplay import OVERVIEW_BANLIST from . import CreateView, PaginationMixin, UpdateView @@ -734,10 +734,10 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View): if idx in self.supported_locale: with language(self.supported_locale[idx], self.request.event.settings.region): if k.startswith('mail_subject_'): - msgs[self.supported_locale[idx]] = bleach.clean(v).format_map(self.placeholders(preview_item)) + msgs[self.supported_locale[idx]] = format_map(bleach.clean(v), self.placeholders(preview_item)) else: msgs[self.supported_locale[idx]] = markdown_compile_email( - v.format_map(self.placeholders(preview_item)) + format_map(v, self.placeholders(preview_item)) ) return JsonResponse({ @@ -761,7 +761,7 @@ class MailSettingsRendererPreview(MailSettingsPreview): def get(self, request, *args, **kwargs): v = str(request.event.settings.mail_text_order_placed) - v = v.format_map(TolerantDict(self.placeholders('mail_text_order_placed'))) + v = format_map(v, self.placeholders('mail_text_order_placed')) renderers = request.event.get_html_mail_renderers() if request.GET.get('renderer') in renderers: with rolledback_transaction(): diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 68e1c5c97..631231c73 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -93,9 +93,7 @@ from pretix.base.services.invoices import ( invoice_qualified, regenerate_invoice, ) from pretix.base.services.locking import LockTimeoutException -from pretix.base.services.mail import ( - SendMailException, TolerantDict, render_mail, -) +from pretix.base.services.mail import SendMailException, render_mail from pretix.base.services.orders import ( OrderChangeManager, OrderError, approve_order, cancel_order, deny_order, extend_order, mark_order_expired, mark_order_refunded, @@ -127,6 +125,7 @@ from pretix.control.forms.orders import ( from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.signals import order_search_forms from pretix.control.views import PaginationMixin +from pretix.helpers.format import format_map from pretix.helpers.safedownload import check_token from pretix.presale.signals import question_form_fields @@ -2032,7 +2031,7 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView): with language(order.locale, self.request.event.settings.region): email_context = get_email_context(event=order.event, order=order) email_template = LazyI18nString(form.cleaned_data['message']) - email_subject = str(form.cleaned_data['subject']).format_map(TolerantDict(email_context)) + email_subject = format_map(str(form.cleaned_data['subject']), email_context) email_content = render_mail(email_template, email_context) if self.request.POST.get('action') == 'preview': self.preview_output = { @@ -2097,7 +2096,7 @@ class OrderPositionSendMail(OrderSendMail): with language(position.order.locale, self.request.event.settings.region): email_context = get_email_context(event=position.order.event, order=position.order, position=position) email_template = LazyI18nString(form.cleaned_data['message']) - email_subject = str(form.cleaned_data['subject']).format_map(TolerantDict(email_context)) + email_subject = format_map(str(form.cleaned_data['subject']), email_context) email_content = render_mail(email_template, email_context) if self.request.POST.get('action') == 'preview': self.preview_output = { diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 10f696560..6bc19f1dc 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -109,6 +109,7 @@ from pretix.control.views import PaginationMixin from pretix.control.views.mailsetup import MailSettingsSetupView from pretix.helpers import GroupConcat from pretix.helpers.dicts import merge_dicts +from pretix.helpers.format import format_map from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.forms.customer import TokenGenerator @@ -335,10 +336,10 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View): if idx in self.supported_locale: with language(self.supported_locale[idx], self.request.organizer.settings.region): if k.startswith('mail_subject_'): - msgs[self.supported_locale[idx]] = bleach.clean(v).format_map(self.placeholders(preview_item)) + msgs[self.supported_locale[idx]] = format_map(bleach.clean(v), self.placeholders(preview_item)) else: msgs[self.supported_locale[idx]] = markdown_compile_email( - v.format_map(self.placeholders(preview_item)) + format_map(v, self.placeholders(preview_item)) ) return JsonResponse({ diff --git a/src/pretix/helpers/format.py b/src/pretix/helpers/format.py new file mode 100644 index 000000000..5621c9d5d --- /dev/null +++ b/src/pretix/helpers/format.py @@ -0,0 +1,34 @@ +import logging +from string import Formatter + +logger = logging.getLogger(__name__) + + +class SafeFormatter(Formatter): + """ + Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and + (b) does not allow any unwanted shenanigans like attribute access or format specifiers. + """ + def __init__(self, context): + self.context = context + + def get_field(self, field_name, args, kwargs): + if '.' in field_name or '[' in field_name: + logger.warning(f'Ignored invalid field name "{field_name}"') + return ('{' + str(field_name) + '}', field_name) + return super().get_field(field_name, args, kwargs) + + def get_value(self, key, args, kwargs): + if key not in self.context: + return '{' + str(key) + '}' + return self.context[key] + + def format_field(self, value, format_spec): + # Ignore format _spec + return super().format_field(value, '') + + +def format_map(template, context): + if not isinstance(template, str): + template = str(template) + return SafeFormatter(context).format(template) diff --git a/src/pretix/plugins/sendmail/tasks.py b/src/pretix/plugins/sendmail/tasks.py index 7bd5b60b2..0a4cdd453 100644 --- a/src/pretix/plugins/sendmail/tasks.py +++ b/src/pretix/plugins/sendmail/tasks.py @@ -40,6 +40,7 @@ from pretix.base.models import Checkin, Event, InvoiceAddress, Order, User from pretix.base.services.mail import SendMailException, mail from pretix.base.services.tasks import ProfiledEventTask from pretix.celery_app import app +from pretix.helpers.format import format_map @app.task(base=ProfiledEventTask, acks_late=True) @@ -116,8 +117,8 @@ def send_mails(event: Event, user: int, subject: dict, message: dict, orders: li user=user, data={ 'position': p.positionid, - 'subject': subject.localize(o.locale).format_map(email_context), - 'message': message.localize(o.locale).format_map(email_context), + 'subject': format_map(subject.localize(o.locale), email_context), + 'message': format_map(message.localize(o.locale), email_context), 'recipient': p.attendee_email } ) @@ -143,8 +144,8 @@ def send_mails(event: Event, user: int, subject: dict, message: dict, orders: li 'pretix.plugins.sendmail.order.email.sent', user=user, data={ - 'subject': subject.localize(o.locale).format_map(email_context), - 'message': message.localize(o.locale).format_map(email_context), + 'subject': format_map(subject.localize(o.locale), email_context), + 'message': format_map(message.localize(o.locale), email_context), 'recipient': o.email } ) diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index accf40112..957107238 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -52,12 +52,12 @@ from pretix.base.email import get_available_placeholders from pretix.base.i18n import LazyI18nString, language from pretix.base.models import Checkin, LogEntry, Order, OrderPosition from pretix.base.models.event import SubEvent -from pretix.base.services.mail import TolerantDict from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.views import CreateView, PaginationMixin, UpdateView from pretix.plugins.sendmail.tasks import send_mails +from ...helpers.format import format_map from . import forms from .models import Rule, ScheduledMail @@ -203,7 +203,7 @@ class SenderView(EventPermissionRequiredMixin, FormView): if self.request.POST.get("action") != "send": for l in self.request.event.settings.locales: with language(l, self.request.event.settings.region): - context_dict = TolerantDict() + context_dict = {} for k, v in get_available_placeholders(self.request.event, ['event', 'order', 'position_or_address']).items(): context_dict[k] = '{}'.format( @@ -212,9 +212,9 @@ class SenderView(EventPermissionRequiredMixin, FormView): ) subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=[]) - preview_subject = subject.format_map(context_dict) + preview_subject = format_map(subject, context_dict) message = form.cleaned_data['message'].localize(l) - preview_text = markdown_compile_email(message.format_map(context_dict)) + preview_text = markdown_compile_email(format_map(message, context_dict)) self.output[l] = { 'subject': _('Subject: {subject}').format(subject=preview_subject), @@ -350,7 +350,7 @@ class CreateRule(EventPermissionRequiredMixin, CreateView): if self.request.POST.get("action") == "preview": for l in self.request.event.settings.locales: with language(l, self.request.event.settings.region): - context_dict = TolerantDict() + context_dict = {} for k, v in get_available_placeholders(self.request.event, ['event', 'order', 'position_or_address']).items(): context_dict[k] = '{}'.format( @@ -359,9 +359,9 @@ class CreateRule(EventPermissionRequiredMixin, CreateView): ) subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=[]) - preview_subject = subject.format_map(context_dict) + preview_subject = format_map(subject, context_dict) template = form.cleaned_data['template'].localize(l) - preview_text = markdown_compile_email(template.format_map(context_dict)) + preview_text = markdown_compile_email(format_map(template, context_dict)) self.output[l] = { 'subject': _('Subject: {subject}').format(subject=preview_subject), @@ -427,7 +427,7 @@ class UpdateRule(EventPermissionRequiredMixin, UpdateView): for lang in self.request.event.settings.locales: with language(lang, self.request.event.settings.region): - placeholders = TolerantDict() + placeholders = {} for k, v in get_available_placeholders(self.request.event, ['event', 'order', 'position_or_address']).items(): placeholders[k] = '{}'.format( _('This value will be replaced based on dynamic parameters.'), @@ -435,9 +435,9 @@ class UpdateRule(EventPermissionRequiredMixin, UpdateView): ) subject = bleach.clean(self.object.subject.localize(lang), tags=[]) - preview_subject = subject.format_map(placeholders) + preview_subject = format_map(subject, placeholders) template = self.object.template.localize(lang) - preview_text = markdown_compile_email(template.format_map(placeholders)) + preview_text = markdown_compile_email(format_map(template, placeholders)) o[lang] = { 'subject': _('Subject: {subject}'.format(subject=preview_subject)), diff --git a/src/pretix/presale/ical.py b/src/pretix/presale/ical.py index 84d3c201f..cab764143 100644 --- a/src/pretix/presale/ical.py +++ b/src/pretix/presale/ical.py @@ -30,6 +30,7 @@ from django.utils.translation import gettext as _ from pretix.base.email import get_email_context from pretix.base.models import Event +from pretix.helpers.format import format_map from pretix.multidomain.urlreverse import build_absolute_uri @@ -112,9 +113,6 @@ def get_private_icals(event, positions): - It would be pretty hard to implement it in a way that doesn't require us to use distinct settings fields for emails to customers and to attendees, which feels like an overcomplication. """ - - from pretix.base.services.mail import TolerantDict - tz = pytz.timezone(event.settings.timezone) creation_time = datetime.datetime.now(pytz.utc) @@ -131,7 +129,7 @@ def get_private_icals(event, positions): if event.settings.mail_attach_ical_description: ctx = get_email_context(event=event, event_or_subevent=ev) - description = str(event.settings.mail_attach_ical_description).format_map(TolerantDict(ctx)) + description = format_map(str(event.settings.mail_attach_ical_description), ctx) else: # Default description descr = [] diff --git a/src/tests/helpers/test_format.py b/src/tests/helpers/test_format.py new file mode 100644 index 000000000..796ba3d06 --- /dev/null +++ b/src/tests/helpers/test_format.py @@ -0,0 +1,9 @@ +from pretix.helpers.format import format_map + + +def test_format_map(): + assert format_map("Foo {bar}", {"bar": 3}) == "Foo 3" + 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!s}", {"bar": 3}) == "Foo 3" + assert format_map("Foo {bar:<20}", {"bar": 3}) == "Foo 3"