From f0a76a3ee0a378c572fed818570d914163be2e26 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Tue, 13 Feb 2024 10:20:12 +0100 Subject: [PATCH] Add register_event_fonts signal and facilities for web-embedded webfonts --- src/pretix/base/middleware.py | 21 +++++- src/pretix/base/pdf.py | 12 ++- src/pretix/base/settings.py | 2 +- src/pretix/base/templatetags/startswith.py | 10 +++ src/pretix/control/forms/event.py | 4 + .../pretixcontrol/event/settings.html | 2 +- .../templates/pretixcontrol/pdf/index.html | 2 +- .../templates/pretixcontrol/pdf/webfonts.css | 8 +- src/pretix/control/views/pdf.py | 4 +- src/pretix/plugins/ticketoutputpdf/signals.py | 2 +- .../plugins/ticketoutputpdf/ticketoutput.py | 2 +- src/pretix/plugins/ticketoutputpdf/views.py | 2 +- src/pretix/presale/style.py | 73 ++++++++++++++++--- 13 files changed, 117 insertions(+), 27 deletions(-) create mode 100644 src/pretix/base/templatetags/startswith.py diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 6b96d68cb7..bb68894dd0 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -20,7 +20,7 @@ # . # from collections import OrderedDict -from urllib.parse import urlsplit +from urllib.parse import urlsplit, urlparse from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from django.conf import settings @@ -40,6 +40,7 @@ from pretix.base.settings import global_settings_object from pretix.multidomain.urlreverse import ( get_event_domain, get_organizer_domain, ) +from pretix.presale.style import get_fonts _supported = None @@ -240,6 +241,14 @@ class SecurityMiddleware(MiddlewareMixin): ) def process_response(self, request, resp): + def nested_dict_values(d): + for v in d.values(): + if isinstance(v, dict): + yield from nested_dict_values(v) + else: + if isinstance(v, str): + yield v + url = resolve(request.path_info) if settings.DEBUG and resp.status_code >= 400: @@ -259,6 +268,14 @@ class SecurityMiddleware(MiddlewareMixin): if gs.settings.leaflet_tiles: img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*")) + font_src = set() + if hasattr(request, 'event'): + for font in get_fonts(request.event, pdf_only=False).values(): + for path in list(nested_dict_values(font)): + font_location = urlparse(path) + if font_location.scheme and font_location.netloc: + font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc)) + h = { 'default-src': ["{static}"], 'script-src': ['{static}'], @@ -267,7 +284,7 @@ class SecurityMiddleware(MiddlewareMixin): 'style-src': ["{static}", "{media}"], 'connect-src': ["{dynamic}", "{media}"], 'img-src': ["{static}", "{media}", "data:"] + img_src, - 'font-src': ["{static}"], + 'font-src': ["{static}"] + list(font_src), 'media-src': ["{static}", "data:"], # form-action is not only used to match on form actions, but also on URLs # form-actions redirect to. In the context of e.g. payment providers or diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 7224066a1f..dbefef1e9e 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -78,7 +78,7 @@ from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import Paragraph from pretix.base.i18n import language -from pretix.base.models import Order, OrderPosition, Question +from pretix.base.models import Order, OrderPosition, Question, Event from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.templatetags.money import money_filter @@ -740,7 +740,7 @@ class Renderer: self.bg_pdf = None @classmethod - def _register_fonts(cls): + def _register_fonts(cls, event: Event = None): if hasattr(cls, '_fonts_registered'): return pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf'))) @@ -748,7 +748,7 @@ class Renderer: pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf'))) pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf'))) - for family, styles in get_fonts().items(): + for family, styles in get_fonts(event, pdf_only=True).items(): pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype']))) if 'italic' in styles: pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype']))) @@ -939,6 +939,12 @@ class Renderer: if o['italic']: font += ' I' + # Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they + # should not have access to. + if font not in list(get_fonts(order.event, pdf_only=True).keys()) + ['Open Sans']: + logger.warning(f'Unauthorized use of font "{font}"') + font = 'Open Sans' + try: ad = getAscentDescent(font, float(o['fontsize'])) except KeyError: # font not known, fall back diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 3c997101b2..a58797d22e 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -89,7 +89,7 @@ def primary_font_kwargs(): choices = [('Open Sans', 'Open Sans')] choices += sorted([ - (a, {"title": a, "data": v}) for a, v in get_fonts().items() if not v.get('pdf_only', False) + (a, {"title": a, "data": v}) for a, v in get_fonts(pdf_only=False).items() ], key=lambda a: a[0]) return { 'choices': choices, diff --git a/src/pretix/base/templatetags/startswith.py b/src/pretix/base/templatetags/startswith.py new file mode 100644 index 0000000000..561b7d5a19 --- /dev/null +++ b/src/pretix/base/templatetags/startswith.py @@ -0,0 +1,10 @@ +from django import template +from django.template.defaultfilters import stringfilter + +register = template.Library() + + +@register.filter('startswith') +@stringfilter +def startswith(text, start): + return text.startswith(start) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 8cd0a74c42..b111a7e7f9 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -79,6 +79,7 @@ from pretix.helpers.countries import CachedCountries from pretix.multidomain.models import KnownDomain from pretix.multidomain.urlreverse import build_absolute_uri from pretix.plugins.banktransfer.payment import BankTransfer +from pretix.presale.style import get_fonts class EventWizardFoundationForm(forms.Form): @@ -651,6 +652,9 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett del self.fields['event_list_available_only'] del self.fields['event_list_filters'] del self.fields['event_calendar_future_only'] + self.fields['primary_font'].choices += [ + (a, {"title": a, "data": v}) for a, v in get_fonts(self.event, pdf_only=False).items() + ] # create "virtual" fields for better UX when editing _asked and _required fields self.virtual_keys = [] diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 9b908c7fab..4adff78aea 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -7,7 +7,7 @@ {% block title %}{% trans "General settings" %}{% endblock %} {% block custom_header %} {{ block.super }} - + {% endblock %} {% block inside %}

{% trans "General settings" %}

diff --git a/src/pretix/control/templates/pretixcontrol/pdf/index.html b/src/pretix/control/templates/pretixcontrol/pdf/index.html index e1160c95ba..8dfb1a7495 100644 --- a/src/pretix/control/templates/pretixcontrol/pdf/index.html +++ b/src/pretix/control/templates/pretixcontrol/pdf/index.html @@ -8,7 +8,7 @@ {% compress css %} {% endcompress %} - + {% endblock %} {% block content %}

diff --git a/src/pretix/control/templates/pretixcontrol/pdf/webfonts.css b/src/pretix/control/templates/pretixcontrol/pdf/webfonts.css index 4c627cc79c..44c11024af 100644 --- a/src/pretix/control/templates/pretixcontrol/pdf/webfonts.css +++ b/src/pretix/control/templates/pretixcontrol/pdf/webfonts.css @@ -1,4 +1,6 @@ {% load static %} +{% load startswith %} + @font-face { font-family: 'AND'; font-style: normal; @@ -27,9 +29,9 @@ {% else %} font-weight: normal; {% endif %} - src: {% if "woff2" in formats %}url('{% static formats.woff2 %}') format('woff2'),{% endif %} - {% if "woff" in formats %}url('{% static formats.woff %}') format('woff'),{% endif %} - {% if "truetype" in formats %}url('{% static formats.truetype %}') format('truetype'){% endif %}; + src: {% if "woff2" in formats %}{% if formats.woff2|startswith:'https' %}url('{{ formats.woff2 }}'){% else %}url('{% static formats.woff2 %}'){% endif %} format('woff2'),{% endif %} + {% if "woff" in formats %}{% if formats.woff|startswith:'https' %}url('{{ formats.woff }}'){% else %}url('{% static formats.woff %}'){% endif %} format('woff'),{% endif %} + {% if "truetype" in formats %}{% if formats.truetype|startswith:'https' %}url('{{ formats.truetype }}'){% else %}url('{% static formats.truetype %}'){% endif %} format('truetype'){% endif %}; } .preload-font[data-family="{{family}}"][data-style="{{style}}"] { font-family: '{{ family }}', 'AND'; diff --git a/src/pretix/control/views/pdf.py b/src/pretix/control/views/pdf.py index afd29115a3..0093efddf5 100644 --- a/src/pretix/control/views/pdf.py +++ b/src/pretix/control/views/pdf.py @@ -262,7 +262,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['fonts'] = get_fonts() + ctx['fonts'] = get_fonts(self.request.event, pdf_only=True) ctx['pdf'] = self.get_current_background() ctx['variables'] = self.get_variables() ctx['images'] = self.get_images() @@ -278,7 +278,7 @@ class FontsCSSView(TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['fonts'] = get_fonts() + ctx['fonts'] = get_fonts(self.request.event if hasattr(self.request, 'event') else None) return ctx diff --git a/src/pretix/plugins/ticketoutputpdf/signals.py b/src/pretix/plugins/ticketoutputpdf/signals.py index b5f467ef30..4cbb779fc3 100644 --- a/src/pretix/plugins/ticketoutputpdf/signals.py +++ b/src/pretix/plugins/ticketoutputpdf/signals.py @@ -41,7 +41,7 @@ from pretix.plugins.ticketoutputpdf.models import ( TicketLayout, TicketLayoutItem, ) from pretix.presale.style import ( # NOQA: legacy import - get_fonts, register_fonts, + get_fonts, register_event_fonts, register_fonts, ) diff --git a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py index 66e9261651..e8c797a14c 100644 --- a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py +++ b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py @@ -92,7 +92,7 @@ class PdfTicketOutput(BaseTicketOutput): return self.event._ticketoutputpdf_cache_default_layout def _register_fonts(self): - Renderer._register_fonts() + Renderer._register_fonts(self.event) def _draw_page(self, layout: TicketLayout, op: OrderPosition, order: Order): buffer = BytesIO() diff --git a/src/pretix/plugins/ticketoutputpdf/views.py b/src/pretix/plugins/ticketoutputpdf/views.py index b48111eafe..bf908fcea1 100644 --- a/src/pretix/plugins/ticketoutputpdf/views.py +++ b/src/pretix/plugins/ticketoutputpdf/views.py @@ -264,7 +264,7 @@ class LayoutEditorView(BaseEditorView): return static('pretixpresale/pdf/ticket_default_a4.pdf') def generate(self, op: OrderPosition, override_layout=None, override_background=None): - Renderer._register_fonts() + Renderer._register_fonts(self.request.event) buffer = BytesIO() if override_background: diff --git a/src/pretix/presale/style.py b/src/pretix/presale/style.py index 1465d5d28f..fab5503de1 100644 --- a/src/pretix/presale/style.py +++ b/src/pretix/presale/style.py @@ -41,6 +41,7 @@ from pretix.base.models import Event, Event_SettingsStore, Organizer from pretix.base.services.tasks import ( TransactionAwareProfiledEventTask, TransactionAwareTask, ) +from pretix.base.signals import EventPluginSignal from pretix.celery_app import app from pretix.multidomain.urlreverse import ( get_event_domain, get_organizer_domain, @@ -86,7 +87,7 @@ def compile_scss(object, file="main.scss", fonts=True): font = object.settings.get('primary_font') if font != 'Open Sans' and fonts: - sassrules.append(get_font_stylesheet(font)) + sassrules.append(get_font_stylesheet(font, event=object if isinstance(object, Event) else None)) sassrules.append( '$font-family-sans-serif: "{}", "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif ' '!default'.format( @@ -225,17 +226,67 @@ Return a dictionaries of the following structure. Paths should be relative to st } """ +register_event_fonts = EventPluginSignal() +""" +Return a dictionaries of the following structure. Paths should be relative to static root. +As with all plugin signals, the ``sender`` keyword argument will contain the event. +{ + "font name": { + "regular": { + "truetype": "….ttf", + "woff": "…", + "woff2": "…" + }, + "bold": { + ... + }, + "italic": { + ... + }, + "bolditalic": { + ... + }, + "pdf_only": False, # if True, font is not usable on the web, + } +} +""" + + +def get_fonts(event: Event = None, pdf_only=False): + def nested_dict_values(d): + for v in d.values(): + if isinstance(v, dict): + yield from nested_dict_values(v) + else: + if isinstance(v, str): + yield v -def get_fonts(): f = {} + received_fonts = {} + for recv, value in register_fonts.send(0): - f.update(value) + received_fonts.update(value) + + if event: + for recv, value in register_event_fonts.send(event): + received_fonts.update(value) + + for font, payload in received_fonts.items(): + if pdf_only: + if any(v.startswith('https') for v in list(nested_dict_values(payload))): + continue + f.update({font: payload}) + else: + if payload.get('pdf_only', False): + continue + f.update({font: payload}) + return f -def get_font_stylesheet(font_name): +def get_font_stylesheet(font_name, event: Event = None): stylesheet = [] - font = get_fonts()[font_name] + font = get_fonts(event)[font_name] for sty, formats in font.items(): if sty == 'sample': continue @@ -251,12 +302,12 @@ def get_font_stylesheet(font_name): stylesheet.append("font-weight: normal;") srcs = [] - if "woff2" in formats: - srcs.append("url(static('{}')) format('woff2')".format(formats['woff2'])) - if "woff" in formats: - srcs.append("url(static('{}')) format('woff')".format(formats['woff'])) - if "truetype" in formats: - srcs.append("url(static('{}')) format('truetype')".format(formats['truetype'])) + for f in ["woff2", "woff", "truetype"]: + if f in formats: + if formats[f].startswith('https'): + srcs.append(f"url('{formats[f]}') format('{f}')") + else: + srcs.append(f"url(static('{formats[f]}')) format('{f}')") stylesheet.append("src: {};".format(", ".join(srcs))) stylesheet.append("font-display: swap;") stylesheet.append("}")