From d67f5c650c1e34bd5ed0bae277c9d16e77d5abfd Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Tue, 26 Mar 2024 09:55:08 +0100 Subject: [PATCH] Event-specific fonts and Web-Embedded Fonts (Z#23130701) (#3893) --- doc/development/api/invoice.rst | 2 + src/pretix/base/invoice.py | 2 +- src/pretix/base/middleware.py | 21 ++++- src/pretix/base/pdf.py | 13 +++- src/pretix/base/settings.py | 2 +- src/pretix/control/forms/event.py | 7 ++ .../pretixcontrol/event/settings.html | 2 +- .../templates/pretixcontrol/pdf/index.html | 2 +- .../templates/pretixcontrol/pdf/webfonts.css | 9 ++- 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 | 78 ++++++++++++++++--- 14 files changed, 118 insertions(+), 30 deletions(-) diff --git a/doc/development/api/invoice.rst b/doc/development/api/invoice.rst index 69dcc222d..7b62a3727 100644 --- a/doc/development/api/invoice.rst +++ b/doc/development/api/invoice.rst @@ -84,6 +84,8 @@ convenient to you: .. automethod:: _register_fonts + .. automethod:: _register_event_fonts + .. automethod:: _on_first_page .. automethod:: _on_other_page diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py index 81ffc5555..0b391cfd0 100644 --- a/src/pretix/base/invoice.py +++ b/src/pretix/base/invoice.py @@ -182,7 +182,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', italic='OpenSansIt', boldItalic='OpenSansBI') - for family, styles in get_fonts().items(): + for family, styles in get_fonts(event=self.event, pdf_support_required=True).items(): if family == self.event.settings.invoice_renderer_font: pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype']))) self.font_regular = family diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 6b96d68cb..dbca8e7ba 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 urlparse, urlsplit 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_support_required=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 7224066a1..cb2d94616 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 Event, Order, OrderPosition, Question 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 @@ -738,9 +738,10 @@ class Renderer: else: self.bg_bytes = None self.bg_pdf = None + self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans'] @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 +749,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_support_required=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 +940,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 self.event_fonts: + 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 77fe13100..6eab68fd8 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_support_required=False).items() ], key=lambda a: a[0]) return { 'choices': choices, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 49f6fcdc9..d530e8cc4 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): @@ -652,6 +653,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_support_required=False).items() + ] # create "virtual" fields for better UX when editing _asked and _required fields self.virtual_keys = [] @@ -932,6 +936,9 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): ) ) self.fields['invoice_generate'].choices = generate_choices + self.fields['invoice_renderer_font'].choices += [ + (a, a) for a in get_fonts(event, pdf_support_required=True).keys() + ] def contains_web_channel_validate(val): diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 1b2998c8c..3c74d9d2b 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 36cac79df..d9198fa9f 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 4c627cc79..61c2a5a37 100644 --- a/src/pretix/control/templates/pretixcontrol/pdf/webfonts.css +++ b/src/pretix/control/templates/pretixcontrol/pdf/webfonts.css @@ -1,4 +1,5 @@ {% load static %} + @font-face { font-family: 'AND'; font-style: normal; @@ -14,7 +15,7 @@ {% for family, styles in fonts.items %} {% for style, formats in styles.items %} -{% if "sample" not in style %} +{% if "sample" not in style and "pdf_only" not in style %} @font-face { font-family: '{{ family }}'; {% if style == "italic" or style == "bolditalic" %} @@ -27,9 +28,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 '//' in formats.woff2 %}url('{{ formats.woff2 }}'){% else %}url('{% static formats.woff2 %}'){% endif %} format('woff2'),{% endif %} + {% if "woff" in formats %}{% if '//' in formats.woff %}url('{{ formats.woff }}'){% else %}url('{% static formats.woff %}'){% endif %} format('woff'),{% endif %} + {% if "truetype" in formats %}{% if '//' in formats.truetype %}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 afd29115a..9407cb949 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_support_required=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, pdf_support_required=True) return ctx diff --git a/src/pretix/plugins/ticketoutputpdf/signals.py b/src/pretix/plugins/ticketoutputpdf/signals.py index b5f467ef3..4cbb779fc 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 66e926165..e8c797a14 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 b48111eaf..bf908fcea 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 1465d5d28..793ca7669 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( @@ -202,7 +203,8 @@ def regenerate_organizer_css(organizer_id: int, regenerate_events=True): register_fonts = Signal() """ -Return a dictionaries of the following structure. Paths should be relative to static root. +Return a dictionaries of the following structure. Paths should be relative to static root or an absolute URL. In the +latter case, the fonts won't be available for PDF-rendering. { "font name": { @@ -225,17 +227,69 @@ 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 or an absolute URL. In the +latter case, the fonts won't be available for PDF-rendering. +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_support_required=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_support_required: + if any('//' in v 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 +305,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("}")