mirror of
https://github.com/pretix/pretix.git
synced 2025-12-05 21:32:28 +00:00
Compare commits
5 Commits
ci-more-th
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91498f17b4 | ||
|
|
8920f2ec31 | ||
|
|
80eb6826b3 | ||
|
|
5922403d40 | ||
|
|
a8d6aec22a |
@@ -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__ = "4.7.0"
|
__version__ = "4.7.1"
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
# 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 io
|
import io
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import OrderedDict, namedtuple
|
from collections import OrderedDict, namedtuple
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -46,26 +45,13 @@ from django.conf import settings
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.utils.formats import localize
|
from django.utils.formats import localize
|
||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from openpyxl import Workbook
|
|
||||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, Cell
|
|
||||||
|
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
|
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
|
||||||
|
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||||
|
)
|
||||||
|
|
||||||
|
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
|
||||||
def excel_safe(val):
|
|
||||||
if isinstance(val, Cell):
|
|
||||||
return val
|
|
||||||
|
|
||||||
if not isinstance(val, KNOWN_TYPES):
|
|
||||||
val = str(val)
|
|
||||||
|
|
||||||
if isinstance(val, bytes):
|
|
||||||
val = val.decode("utf-8", errors="ignore")
|
|
||||||
|
|
||||||
if isinstance(val, str):
|
|
||||||
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
|
|
||||||
|
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
class BaseExporter:
|
class BaseExporter:
|
||||||
@@ -228,7 +214,7 @@ class ListExporter(BaseExporter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _render_xlsx(self, form_data, output_file=None):
|
def _render_xlsx(self, form_data, output_file=None):
|
||||||
wb = Workbook(write_only=True)
|
wb = SafeWorkbook(write_only=True)
|
||||||
ws = wb.create_sheet()
|
ws = wb.create_sheet()
|
||||||
self.prepare_xlsx_sheet(ws)
|
self.prepare_xlsx_sheet(ws)
|
||||||
try:
|
try:
|
||||||
@@ -242,7 +228,7 @@ class ListExporter(BaseExporter):
|
|||||||
total = line.total
|
total = line.total
|
||||||
continue
|
continue
|
||||||
ws.append([
|
ws.append([
|
||||||
excel_safe(val) for val in line
|
val for val in line
|
||||||
])
|
])
|
||||||
if total:
|
if total:
|
||||||
counter += 1
|
counter += 1
|
||||||
@@ -347,7 +333,7 @@ class MultiSheetListExporter(ListExporter):
|
|||||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
def _render_xlsx(self, form_data, output_file=None):
|
def _render_xlsx(self, form_data, output_file=None):
|
||||||
wb = Workbook(write_only=True)
|
wb = SafeWorkbook(write_only=True)
|
||||||
n_sheets = len(self.sheets)
|
n_sheets = len(self.sheets)
|
||||||
for i_sheet, (s, l) in enumerate(self.sheets):
|
for i_sheet, (s, l) in enumerate(self.sheets):
|
||||||
ws = wb.create_sheet(str(l))
|
ws = wb.create_sheet(str(l))
|
||||||
@@ -361,8 +347,7 @@ class MultiSheetListExporter(ListExporter):
|
|||||||
total = line.total
|
total = line.total
|
||||||
continue
|
continue
|
||||||
ws.append([
|
ws.append([
|
||||||
excel_safe(val)
|
val for val in line
|
||||||
for val in line
|
|
||||||
])
|
])
|
||||||
if total:
|
if total:
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|||||||
@@ -705,7 +705,7 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
label=label, required=required,
|
label=label, required=required,
|
||||||
min_value=q.valid_number_min or Decimal('0.00'),
|
min_value=q.valid_number_min or Decimal('0.00'),
|
||||||
max_value=q.valid_number_max,
|
max_value=q.valid_number_max,
|
||||||
help_text=q.help_text,
|
help_text=help_text,
|
||||||
initial=initial.answer if initial else None,
|
initial=initial.answer if initial else None,
|
||||||
)
|
)
|
||||||
elif q.type == Question.TYPE_STRING:
|
elif q.type == Question.TYPE_STRING:
|
||||||
|
|||||||
113
src/pretix/helpers/safe_openpyxl.py
Normal file
113
src/pretix/helpers/safe_openpyxl.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import re
|
||||||
|
from inspect import isgenerator
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.cell.cell import (
|
||||||
|
ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, TIME_TYPES, TYPE_FORMULA, TYPE_STRING,
|
||||||
|
Cell,
|
||||||
|
)
|
||||||
|
from openpyxl.compat import NUMERIC_TYPES
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
from openpyxl.utils.exceptions import ReadOnlyWorkbookException
|
||||||
|
from openpyxl.worksheet._write_only import WriteOnlyWorksheet
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
|
||||||
|
SAFE_TYPES = NUMERIC_TYPES + TIME_TYPES + (bool, type(None))
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
This module provides a safer version of openpyxl's `Workbook` class to generate XLSX files from
|
||||||
|
user-generated data using `WriteOnlyWorksheet` and `ws.append()`. We commonly use these methods
|
||||||
|
to output e.g. order data, which contains data from untrusted sources such as attendee names.
|
||||||
|
|
||||||
|
There are mainly two problems this solves:
|
||||||
|
|
||||||
|
- It makes sure strings starting with = are treated as text, not as a formula, as openpyxl will
|
||||||
|
otherwise assume, which can be used for remote code execution.
|
||||||
|
|
||||||
|
- It removes characters considered invalid by Excel to avoid exporter crashes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def remove_invalid_excel_chars(val):
|
||||||
|
if isinstance(val, Cell):
|
||||||
|
return val
|
||||||
|
|
||||||
|
if not isinstance(val, KNOWN_TYPES):
|
||||||
|
val = str(val)
|
||||||
|
|
||||||
|
if isinstance(val, bytes):
|
||||||
|
val = val.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
|
||||||
|
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def SafeCell(*args, value=None, **kwargs):
|
||||||
|
value = remove_invalid_excel_chars(value)
|
||||||
|
c = Cell(*args, value=value, **kwargs)
|
||||||
|
if c.data_type == TYPE_FORMULA:
|
||||||
|
c.data_type = TYPE_STRING
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
class SafeAppendMixin:
|
||||||
|
def append(self, iterable):
|
||||||
|
row_idx = self._current_row + 1
|
||||||
|
|
||||||
|
if isinstance(iterable, (list, tuple, range)) or isgenerator(iterable):
|
||||||
|
for col_idx, content in enumerate(iterable, 1):
|
||||||
|
if isinstance(content, Cell):
|
||||||
|
# compatible with write-only mode
|
||||||
|
cell = content
|
||||||
|
if cell.parent and cell.parent != self:
|
||||||
|
raise ValueError("Cells cannot be copied from other worksheets")
|
||||||
|
cell.parent = self
|
||||||
|
cell.column = col_idx
|
||||||
|
cell.row = row_idx
|
||||||
|
else:
|
||||||
|
cell = SafeCell(self, row=row_idx, column=col_idx, value=remove_invalid_excel_chars(content))
|
||||||
|
self._cells[(row_idx, col_idx)] = cell
|
||||||
|
|
||||||
|
elif isinstance(iterable, dict):
|
||||||
|
for col_idx, content in iterable.items():
|
||||||
|
if isinstance(col_idx, str):
|
||||||
|
col_idx = column_index_from_string(col_idx)
|
||||||
|
cell = SafeCell(self, row=row_idx, column=col_idx, value=content)
|
||||||
|
self._cells[(row_idx, col_idx)] = cell
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._invalid_row(iterable)
|
||||||
|
|
||||||
|
self._current_row = row_idx
|
||||||
|
|
||||||
|
|
||||||
|
class SafeWriteOnlyWorksheet(SafeAppendMixin, WriteOnlyWorksheet):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SafeWorksheet(SafeAppendMixin, Worksheet):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SafeWorkbook(Workbook):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self._sheets:
|
||||||
|
# monkeypatch existing sheets
|
||||||
|
for s in self._sheets:
|
||||||
|
s.append = SafeAppendMixin.append
|
||||||
|
|
||||||
|
def create_sheet(self, title=None, index=None):
|
||||||
|
if self.read_only:
|
||||||
|
raise ReadOnlyWorkbookException('Cannot create new sheet in a read-only workbook')
|
||||||
|
|
||||||
|
if self.write_only:
|
||||||
|
new_ws = SafeWriteOnlyWorksheet(parent=self, title=title)
|
||||||
|
else:
|
||||||
|
new_ws = SafeWorksheet(parent=self, title=title)
|
||||||
|
|
||||||
|
self._add_sheet(sheet=new_ws, index=index)
|
||||||
|
return new_ws
|
||||||
@@ -47,6 +47,7 @@ from pretix.base.forms.questions import (
|
|||||||
BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget,
|
BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget,
|
||||||
guess_phone_prefix,
|
guess_phone_prefix,
|
||||||
)
|
)
|
||||||
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.base.validators import EmailBanlistValidator
|
from pretix.base.validators import EmailBanlistValidator
|
||||||
from pretix.presale.signals import contact_form_fields
|
from pretix.presale.signals import contact_form_fields
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ class ContactForm(forms.Form):
|
|||||||
self.fields['phone'] = PhoneNumberField(
|
self.fields['phone'] = PhoneNumberField(
|
||||||
label=_('Phone number'),
|
label=_('Phone number'),
|
||||||
required=self.event.settings.order_phone_required,
|
required=self.event.settings.order_phone_required,
|
||||||
help_text=self.event.settings.checkout_phone_helptext,
|
help_text=rich_text(self.event.settings.checkout_phone_helptext),
|
||||||
widget=WrappedPhoneNumberPrefixWidget()
|
widget=WrappedPhoneNumberPrefixWidget()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ class ContactForm(forms.Form):
|
|||||||
# is an autofocus field. Who would have thought… See e.g. here:
|
# is an autofocus field. Who would have thought… See e.g. here:
|
||||||
# https://floatboxjs.com/forum/topic.php?post=8440&usebb_sid=2e116486a9ec6b7070e045aea8cded5b#post8440
|
# https://floatboxjs.com/forum/topic.php?post=8440&usebb_sid=2e116486a9ec6b7070e045aea8cded5b#post8440
|
||||||
self.fields['email'].widget.attrs['autofocus'] = 'autofocus'
|
self.fields['email'].widget.attrs['autofocus'] = 'autofocus'
|
||||||
self.fields['email'].help_text = self.event.settings.checkout_email_helptext
|
self.fields['email'].help_text = rich_text(self.event.settings.checkout_email_helptext)
|
||||||
|
|
||||||
responses = contact_form_fields.send(self.event, request=self.request)
|
responses = contact_form_fields.send(self.event, request=self.request)
|
||||||
for r, response in responses:
|
for r, response in responses:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from pretix.base.forms.questions import (
|
|||||||
NamePartsFormField, WrappedPhoneNumberPrefixWidget, guess_phone_prefix,
|
NamePartsFormField, WrappedPhoneNumberPrefixWidget, guess_phone_prefix,
|
||||||
)
|
)
|
||||||
from pretix.base.models import Quota, WaitingListEntry
|
from pretix.base.models import Quota, WaitingListEntry
|
||||||
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.presale.views.event import get_grouped_items
|
from pretix.presale.views.event import get_grouped_items
|
||||||
|
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ class WaitingListForm(forms.ModelForm):
|
|||||||
self.fields['phone'] = PhoneNumberField(
|
self.fields['phone'] = PhoneNumberField(
|
||||||
label=_("Phone number"),
|
label=_("Phone number"),
|
||||||
required=event.settings.waiting_list_phones_required,
|
required=event.settings.waiting_list_phones_required,
|
||||||
help_text=event.settings.waiting_list_phones_explanation_text,
|
help_text=rich_text(event.settings.waiting_list_phones_explanation_text),
|
||||||
widget=WrappedPhoneNumberPrefixWidget()
|
widget=WrappedPhoneNumberPrefixWidget()
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ $(function () {
|
|||||||
// multi-input fields have a role=group with aria-labelledby
|
// multi-input fields have a role=group with aria-labelledby
|
||||||
var label = this.hasAttribute("aria-labelledby") ? $("#" + this.getAttribute("aria-labelledby")) : $("[for="+target.attr("id")+"]");
|
var label = this.hasAttribute("aria-labelledby") ? $("#" + this.getAttribute("aria-labelledby")) : $("[for="+target.attr("id")+"]");
|
||||||
|
|
||||||
content.append("<li><a href='#" + target.attr("id") + "'>" + label.get(0).childNodes[0].nodeValue + "</a>: "+desc.text()+"</li>");
|
var $li = $("<li>");
|
||||||
|
$li.text(": " + desc.text())
|
||||||
|
$li.prepend($("<a>").attr("href", "#" + target.attr("id")).text(label.get(0).childNodes[0].nodeValue))
|
||||||
|
content.append($li);
|
||||||
});
|
});
|
||||||
$(this).append(content);
|
$(this).append(content);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ setup(
|
|||||||
'Django==3.2.*',
|
'Django==3.2.*',
|
||||||
'django-bootstrap3==15.0.*',
|
'django-bootstrap3==15.0.*',
|
||||||
'django-compressor==2.4.*',
|
'django-compressor==2.4.*',
|
||||||
'django-countries>=7.2',
|
'django-countries==7.2.*',
|
||||||
'django-filter==21.1',
|
'django-filter==21.1',
|
||||||
'django-formset-js-improved==0.5.0.2',
|
'django-formset-js-improved==0.5.0.2',
|
||||||
'django-formtools==2.3',
|
'django-formtools==2.3',
|
||||||
|
|||||||
38
src/tests/helpers/test_safe_openpyxl.py
Normal file
38
src/tests/helpers/test_safe_openpyxl.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from openpyxl.cell.cell import TYPE_STRING
|
||||||
|
|
||||||
|
from pretix.helpers.safe_openpyxl import SafeWorkbook
|
||||||
|
|
||||||
|
|
||||||
|
def test_nullbyte_removed():
|
||||||
|
wb = SafeWorkbook()
|
||||||
|
ws = wb.create_sheet()
|
||||||
|
ws.append(["foo\u0000bar"])
|
||||||
|
assert ws.cell(1, 1).value == "foobar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_formulas():
|
||||||
|
wb = SafeWorkbook()
|
||||||
|
ws = wb.create_sheet()
|
||||||
|
ws.append(["=1+1"])
|
||||||
|
assert ws.cell(1, 1).data_type == TYPE_STRING
|
||||||
Reference in New Issue
Block a user