diff --git a/src/onegov/agency/forms/agency.py b/src/onegov/agency/forms/agency.py index b80029ee0a..8dc860f930 100644 --- a/src/onegov/agency/forms/agency.py +++ b/src/onegov/agency/forms/agency.py @@ -16,7 +16,7 @@ from onegov.form.fields import ChosenSelectField, HtmlField from onegov.form.fields import MultiCheckboxField from onegov.form.fields import UploadField -from onegov.form.validators import FileSizeLimit +from onegov.form.validators import FileSizeLimit, MIME_TYPES_IMAGE from onegov.form.validators import WhitelistedMimeType from onegov.gis import CoordinatesField from sqlalchemy import func @@ -73,10 +73,7 @@ class ExtendedAgencyForm(Form): organigram = UploadField( label=_('Organigram'), validators=[ - WhitelistedMimeType({ - 'image/jpeg', - 'image/png', - }), + WhitelistedMimeType(MIME_TYPES_IMAGE), FileSizeLimit(1 * 1024 * 1024) ] ) diff --git a/src/onegov/election_day/forms/election.py b/src/onegov/election_day/forms/election.py index 2c720e4933..9d4ff54478 100644 --- a/src/onegov/election_day/forms/election.py +++ b/src/onegov/election_day/forms/election.py @@ -13,7 +13,7 @@ from onegov.form.fields import ChosenSelectMultipleField from onegov.form.fields import PanelField from onegov.form.fields import UploadField -from onegov.form.validators import FileSizeLimit +from onegov.form.validators import FileSizeLimit, MIME_TYPES_PDF from onegov.form.validators import WhitelistedMimeType from re import findall from sqlalchemy import or_ @@ -323,7 +323,7 @@ class ElectionForm(Form): explanations_pdf = UploadField( label=_('Explanations (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ], fieldset=_('Related link') diff --git a/src/onegov/election_day/forms/election_compound.py b/src/onegov/election_day/forms/election_compound.py index 61be3c5e4c..76db82f6e8 100644 --- a/src/onegov/election_day/forms/election_compound.py +++ b/src/onegov/election_day/forms/election_compound.py @@ -12,7 +12,7 @@ from onegov.form.fields import ChosenSelectMultipleField from onegov.form.fields import PanelField from onegov.form.fields import UploadField -from onegov.form.validators import FileSizeLimit +from onegov.form.validators import FileSizeLimit, MIME_TYPES_PDF from onegov.form.validators import WhitelistedMimeType from re import findall from sqlalchemy import or_ @@ -228,7 +228,7 @@ class ElectionCompoundForm(Form): explanations_pdf = UploadField( label=_('Explanations (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ], fieldset=_('Related link') @@ -237,7 +237,7 @@ class ElectionCompoundForm(Form): upper_apportionment_pdf = UploadField( label=_('Upper apportionment (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ], fieldset=_('Related link'), @@ -247,7 +247,7 @@ class ElectionCompoundForm(Form): lower_apportionment_pdf = UploadField( label=_('Lower apportionment (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ], fieldset=_('Related link'), diff --git a/src/onegov/election_day/forms/upload/common.py b/src/onegov/election_day/forms/upload/common.py index e50464640e..2a527194b8 100644 --- a/src/onegov/election_day/forms/upload/common.py +++ b/src/onegov/election_day/forms/upload/common.py @@ -11,8 +11,8 @@ 'text/csv' } ALLOWED_MIME_TYPES_XML = { - 'application/xml', - 'text/xml', + 'application/xml', # official, standard + 'text/xml', # deprecated MIME type for XML content 'text/plain' } diff --git a/src/onegov/election_day/forms/vote.py b/src/onegov/election_day/forms/vote.py index 1719e07baa..1b5df4a516 100644 --- a/src/onegov/election_day/forms/vote.py +++ b/src/onegov/election_day/forms/vote.py @@ -10,7 +10,7 @@ from onegov.form.fields import ChosenSelectField from onegov.form.fields import PanelField from onegov.form.fields import UploadField -from onegov.form.validators import FileSizeLimit +from onegov.form.validators import FileSizeLimit, MIME_TYPES_PDF from onegov.form.validators import WhitelistedMimeType from wtforms.fields import BooleanField from wtforms.fields import DateField @@ -280,7 +280,7 @@ class VoteForm(Form): explanations_pdf = UploadField( label=_('Explanations (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ], fieldset=_('Related link') diff --git a/src/onegov/file/utils.py b/src/onegov/file/utils.py index f9daa6a386..75a8ce1549 100644 --- a/src/onegov/file/utils.py +++ b/src/onegov/file/utils.py @@ -74,15 +74,8 @@ def get_supported_image_mime_types() -> set[str]: # Not all PIL formats register a mime type, fill in the blanks ourselves. supported_types = { - 'image/bmp', - 'image/x-bmp', 'image/x-MS-bmp', - 'image/x-icon', - 'image/x-ico', - 'image/x-win-bitmap', - 'image/x-pcx', - 'image/x-portable-pixmap', - 'image/x-tga' + 'image/x-xcf', } for mime in Image.MIME.values(): diff --git a/src/onegov/form/fields.py b/src/onegov/form/fields.py index fc5d621d3c..1fe93275df 100644 --- a/src/onegov/form/fields.py +++ b/src/onegov/form/fields.py @@ -18,7 +18,7 @@ from onegov.file.utils import IMAGE_MIME_TYPES_AND_SVG from onegov.form import log, _ from onegov.form.utils import path_to_filename -from onegov.form.validators import ValidPhoneNumber +from onegov.form.validators import ValidPhoneNumber, WhitelistedMimeType from onegov.form.widgets import ChosenSelectWidget from onegov.form.widgets import LinkPanelWidget from onegov.form.widgets import DurationInput @@ -59,7 +59,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator, Sequence from datetime import datetime - from onegov.core.types import FileDict as StrictFileDict + from onegov.core.types import FileDict as StrictFileDict, LaxFileDict from onegov.file import File from onegov.form import Form from onegov.form.types import ( @@ -274,28 +274,58 @@ class UploadField(FileField): file: IO[bytes] | None filename: str | None - if TYPE_CHECKING: - def __init__( - self, - label: str | None = None, - validators: Validators[FormT, Self] | None = None, - filters: Sequence[Filter] = (), - description: str = '', - id: str | None = None, - default: Sequence[StrictFileDict] = (), - widget: Widget[Self] | None = None, - render_kw: dict[str, Any] | None = None, - name: str | None = None, - _form: BaseForm | None = None, - _prefix: str = '', - _translations: _SupportsGettextAndNgettext | None = None, - _meta: DefaultMeta | None = None, - # onegov specific kwargs that get popped off - *, - fieldset: str | None = None, - depends_on: Sequence[Any] | None = None, - pricing: PricingRules | None = None, - ): ... + def __init__( + self, + label: str | None = None, + validators: Validators[FormT, Self] | None = None, + filters: Sequence[Filter] = (), + description: str = '', + id: str | None = None, + default: LaxFileDict | None = None, + widget: Widget[Self] | None = None, + render_kw: dict[str, Any] | None = None, + name: str | None = None, + allowed_mimetypes: Sequence[str] | None = None, + _form: BaseForm | None = None, + _prefix: str = '', + _translations: _SupportsGettextAndNgettext | None = None, + _meta: DefaultMeta | None = None, + # onegov specific kwargs that get popped off + *, + fieldset: str | None = None, + depends_on: Sequence[Any] | None = None, + pricing: PricingRules | None = None, + ): + validator = ( + WhitelistedMimeType(allowed_mimetypes) + if allowed_mimetypes + else WhitelistedMimeType() + ) + + if validators: + validators = list(validators) + if not any(isinstance(validator, WhitelistedMimeType) + for validator in validators + ): + validators.append(validator) + else: + validators = [validator] + + super().__init__( + label=label, + validators=validators, + filters=filters, + description=description, + id=id, + default=default, + widget=widget, + render_kw=render_kw, + name=name, + _form=_form, + _prefix=_prefix, + _translations=_translations, + _meta=_meta, + ) # this is not quite accurate, since it is either a dictionary with all # the keys or none of the keys, which would make type narrowing easier @@ -474,6 +504,7 @@ def __init__( render_kw: dict[str, Any] | None = None, name: str | None = None, upload_widget: Widget[UploadField] | None = None, + allowed_mimetypes: Sequence[str] | None = None, _form: BaseForm | None = None, _prefix: str = '', _translations: _SupportsGettextAndNgettext | None = None, @@ -492,11 +523,11 @@ def __init__( # a lot of the arguments we just pass through to the subfield unbound_field = self.upload_field_class( - validators=validators, # type:ignore[arg-type] filters=filters, description=description, widget=upload_widget, render_kw=render_kw, + allowed_mimetypes=allowed_mimetypes, **extra_arguments ) super().__init__( @@ -507,6 +538,7 @@ def __init__( id=id, default=default, widget=widget, # type:ignore[arg-type] + validators=[*(validators or ())], render_kw=render_kw, name=name, _form=_form, diff --git a/src/onegov/form/validators.py b/src/onegov/form/validators.py index 7b0ffd3873..2dc3bcba11 100644 --- a/src/onegov/form/validators.py +++ b/src/onegov/form/validators.py @@ -12,6 +12,8 @@ from decimal import Decimal from dateutil.relativedelta import relativedelta from mimetypes import types_map + +from onegov.file.utils import get_supported_image_mime_types from onegov.form import _ from onegov.form.errors import ( DuplicateLabelError, @@ -36,7 +38,8 @@ from wtforms.validators import ValidationError -from typing import Generic, TYPE_CHECKING +from typing import Generic, TYPE_CHECKING, Any + if TYPE_CHECKING: from collections.abc import Collection, Sequence from onegov.core.orm import Base @@ -119,13 +122,96 @@ def __call__(self, form: Form, field: Field) -> None: if not field.data: return - if field.data.get('size', 0) > self.max_bytes: + if isinstance(field.data, list): # UploadMultipleField + for data in field.data: + if not data: + continue # in case of file deletion + + self.validate_filesize(field, data) + + else: + self.validate_filesize(field, field.data) + + def validate_filesize(self, field: Field, data: dict[Any, Any]) -> None: + if data.get('size', 0) > self.max_bytes: message = field.gettext(self.message).format( humanize.naturalsize(self.max_bytes) ) raise ValidationError(message) +MIME_TYPES_PDF = { + 'application/pdf', +} + +# for now not allowed by default +MIME_TYPES_JSON = { + 'application/json', +} + +MIME_TYPES_DOCUMENT = { + 'application/msword', # doc + 'application/rtf', + *MIME_TYPES_PDF, + 'application/excel', + 'application/vnd.ms-excel', # xls + ('application/vnd.openxmlformats-officedocument.' + 'presentationml.presentation'), # pptx + ('application/vnd.openxmlformats-officedocument.' + 'spreadsheetml.sheet'), # xlsx + ('application/vnd.openxmlformats-officedocument.' + 'wordprocessingml.document'), # docx + 'application/vnd.ms-office', + 'application/CDFV2', # old ms office docs + 'application/x-ole-storage', # old ms office docs + 'application/CDFV2-unknown' # old ms office docs +} + +MIME_TYPES_EXCEL = { + 'application/excel', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-office', + 'application/octet-stream', + 'application/x-ole-storage', +} + +MIME_TYPES_XML = { + 'application/xml', +} + +MIME_TYPES_ARCHIVE = { + 'application/zip', +} + +MIME_TYPES_TEXT_DATA = { + 'text/csv', + 'text/plain', +} + +MIME_TYPES_IMAGE = { + # allowed types based on PIL + *get_supported_image_mime_types(), + 'image/svg+xml', +} + +MIME_TYPES_AUDIO = { + 'audio/mp4', + 'audio/mpeg', + 'audio/wav', + 'audio/webm', # weba +} + +MIME_TYPES_VIDEO = { + 'video/mp4', + 'video/mpeg', # mpg, mpeg + 'video/ogg', + 'video/quicktime', # mov + 'video/webm', # webm + 'video/x-msvideo', # avi +} + + class WhitelistedMimeType: """ Makes sure an uploaded file is in a whitelist of allowed mimetypes. @@ -134,17 +220,13 @@ class WhitelistedMimeType: """ whitelist: Collection[str] = { - 'application/excel', - 'application/vnd.ms-excel', - 'application/msword', - 'application/pdf', - 'application/zip', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/x-ms-bmp', - 'text/plain', - 'text/csv' + *MIME_TYPES_DOCUMENT, + *MIME_TYPES_XML, + *MIME_TYPES_ARCHIVE, + *MIME_TYPES_TEXT_DATA, + *MIME_TYPES_IMAGE, + *MIME_TYPES_AUDIO, + *MIME_TYPES_VIDEO, } message = _('Files of this type are not supported.') @@ -157,8 +239,20 @@ def __call__(self, form: Form, field: Field) -> None: if not field.data: return - if field.data['mimetype'] not in self.whitelist: - raise ValidationError(field.gettext(self.message)) + if isinstance(field.data, list): # UploadMultipleField + for data in field.data: + if not data: + continue # in case of file deletion + + self.validate_mimetype(field, data) + + else: + self.validate_mimetype(field, field.data) + + def validate_mimetype(self, field: Field, data: dict[Any, Any]) -> None: + if data['mimetype'] not in self.whitelist: + message = field.gettext(self.message) + raise ValidationError(field.gettext(message)) class ExpectedExtensions(WhitelistedMimeType): diff --git a/src/onegov/form/widgets.py b/src/onegov/form/widgets.py index 42dffb1b6b..772844849f 100644 --- a/src/onegov/form/widgets.py +++ b/src/onegov/form/widgets.py @@ -305,7 +305,10 @@ def __call__( """) if force_simple or len(field) == 0: - return simple_template.format(input_html) + return simple_template.format(input_html) + Markup('\n').join( + Markup('{}').format(error) + for error in field.errors + ) else: existing_html = Markup('').join( subfield( diff --git a/src/onegov/landsgemeinde/forms/agenda.py b/src/onegov/landsgemeinde/forms/agenda.py index e663d99d88..07f590355c 100644 --- a/src/onegov/landsgemeinde/forms/agenda.py +++ b/src/onegov/landsgemeinde/forms/agenda.py @@ -19,8 +19,12 @@ from onegov.form.fields import TimeField from onegov.form.fields import UploadField from onegov.form.forms import NamedFileForm -from onegov.form.validators import FileSizeLimit -from onegov.form.validators import WhitelistedMimeType +from onegov.form.validators import ( + FileSizeLimit, + MIME_TYPES_PDF, + MIME_TYPES_ARCHIVE, + WhitelistedMimeType +) from onegov.landsgemeinde import _ from onegov.landsgemeinde.layouts import DefaultLayout from onegov.landsgemeinde.models import AgendaItem, LandsgemeindeFile @@ -80,7 +84,7 @@ class AgendaItemForm(NamedFileForm): label=_('Excerpt from the Memorial (PDF)'), fieldset=_('Memorial'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ] ) @@ -226,7 +230,7 @@ class AgendaItemUploadForm(Form): label=_('Agenda Item ZIP'), fieldset=_('Import'), validators=[ - WhitelistedMimeType({'application/zip'}), + WhitelistedMimeType(MIME_TYPES_ARCHIVE), FileSizeLimit(100 * 1024 * 1024) ] ) diff --git a/src/onegov/landsgemeinde/forms/assembly.py b/src/onegov/landsgemeinde/forms/assembly.py index ca4375e0fe..e69affc5bc 100644 --- a/src/onegov/landsgemeinde/forms/assembly.py +++ b/src/onegov/landsgemeinde/forms/assembly.py @@ -7,8 +7,13 @@ from onegov.form.fields import TimeField from onegov.form.fields import UploadField from onegov.form.forms import NamedFileForm -from onegov.form.validators import FileSizeLimit -from onegov.form.validators import WhitelistedMimeType +from onegov.form.validators import ( + FileSizeLimit, + MIME_TYPES_PDF, + MIME_TYPES_AUDIO, + MIME_TYPES_ARCHIVE, + WhitelistedMimeType +) from onegov.landsgemeinde import _ from onegov.landsgemeinde.layouts import DefaultLayout from onegov.landsgemeinde.models import Assembly, LandsgemeindeFile @@ -83,7 +88,7 @@ class AssemblyForm(NamedFileForm): label=_('Memorial part 1 (PDF)'), fieldset=_('Downloads'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ] ) @@ -92,7 +97,7 @@ class AssemblyForm(NamedFileForm): label=_('Memorial part 2 (PDF)'), fieldset=_('Downloads'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ] ) @@ -101,7 +106,7 @@ class AssemblyForm(NamedFileForm): label=_('Supplement to the memorial (PDF)'), fieldset=_('Downloads'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ] ) @@ -110,7 +115,7 @@ class AssemblyForm(NamedFileForm): label=_('Protocol (PDF)'), fieldset=_('Downloads'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024) ] ) @@ -119,7 +124,7 @@ class AssemblyForm(NamedFileForm): label=_('Audio (MP3)'), fieldset=_('Downloads'), validators=[ - WhitelistedMimeType({'audio/mpeg'}), + WhitelistedMimeType(MIME_TYPES_AUDIO), FileSizeLimit(600 * 1024 * 1024) ] ) @@ -128,7 +133,7 @@ class AssemblyForm(NamedFileForm): label=_('Memorial as audio for the visually impaired and blind'), fieldset=_('Downloads'), validators=[ - WhitelistedMimeType({'application/zip'}), + WhitelistedMimeType(MIME_TYPES_ARCHIVE), FileSizeLimit(600 * 1024 * 1024) ] ) diff --git a/src/onegov/org/assets/js/locale.js b/src/onegov/org/assets/js/locale.js index 5771369e55..3a6226d9f0 100644 --- a/src/onegov/org/assets/js/locale.js +++ b/src/onegov/org/assets/js/locale.js @@ -30,7 +30,9 @@ var locales = { "The server responded with an error. We have been informed and will investigate the problem.": "Auf dem Server ist ein Fehler aufgetreten. Wir wurden informiert und werden das Problem analysieren.", "The server could not be reached. Please try again.": "Der Server konnte nicht erreicht werden. Bitte probieren Sie es noch einmal.", "The site could not be found.": "Die Seite wurde nicht gefunden.", - "Access denied. Please log in before continuing.": "Zugriff verweigert. Bitte melden Sie sich an bevor Sie weiterfahren." + "Access denied. Please log in before continuing.": "Zugriff verweigert. Bitte melden Sie sich an bevor Sie weiterfahren.", + "Unsupported Media Type": "Nicht unterstützter Medientyp", + "Upload failed": "Upload fehlgeschlagen" }, fr: { "Allocation": "Allocation", @@ -63,7 +65,9 @@ var locales = { "The server responded with an error. We have been informed and will investigate the problem.": "Le serveur a répondu par une erreur. Nous en avons été informés et nous étudierons le problème.", "The server could not be reached. Please try again.": "Le serveur n'a pas pu être joint. Veuillez réessayer.", "The site could not be found.": "Impossible de trouver le site.", - "Access denied. Please log in before continuing.": "Accès refusé. Veuillez vous connecter avant de continuer." + "Access denied. Please log in before continuing.": "Accès refusé. Veuillez vous connecter avant de continuer.", + "Unsupported Media Type": "Type de média non supporté", + "Upload failed": "L'envoi a échoué" }, it: { "Allocation": "Allocazione", @@ -96,7 +100,9 @@ var locales = { "The server responded with an error. We have been informed and will investigate the problem.": "Il server ha risposto con un errore. Siamo stati informati e indagheremo sul problema.", "The server could not be reached. Please try again.": "Impossibile raggiungere il server. Per favore riprova.", "The site could not be found.": "Impossibile trovare il sito.", - "Access denied. Please log in before continuing.": "Accesso negato. Effettua il login prima di continuare." + "Access denied. Please log in before continuing.": "Accesso negato. Effettua il login prima di continuare.", + "Unsupported Media Type": "Tipo di media non supportato", + "Upload failed": "L'upload non è andato a buon fine" } }; diff --git a/src/onegov/org/assets/js/upload.js b/src/onegov/org/assets/js/upload.js index 203c450408..905b3491c0 100644 --- a/src/onegov/org/assets/js/upload.js +++ b/src/onegov/org/assets/js/upload.js @@ -61,8 +61,15 @@ var Upload = function(element) { processCommonNodes($(xhr.responseText).appendTo(filelist), true); } } else { + var errText = locale(xhr.statusText || 'Upload failed') ; + bar.find('.meter').css('width', '100%'); - bar.addClass('alert').attr('data-error', xhr.statusText); + bar.addClass('has-error').attr('data-error', errText); + bar.find('.upload-error').text(errText).attr('aria-hidden', 'false').show(); + bar.attr('title', errText); + + // keep the bar visible but mark as failed + bar.find('.upload-filename').addClass('muted'); } // eslint-disable-next-line no-use-before-define @@ -82,9 +89,19 @@ var Upload = function(element) { }; var queue_upload = function(file) { - var bar = $('
') - .attr('data-filename', file.name) - .prependTo(progress); + var bar = $( + '
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + ).attr('data-filename', file.name) + .prependTo(progress); + + bar.find('.upload-filename').text(file.name); + upload_queue.push({file: file, bar: bar}); }; diff --git a/src/onegov/org/forms/event.py b/src/onegov/org/forms/event.py index 4f571e1c00..9f8d68dd0f 100644 --- a/src/onegov/org/forms/event.py +++ b/src/onegov/org/forms/event.py @@ -21,7 +21,11 @@ from onegov.form.fields import UploadFileWithORMSupport from onegov.form.utils import get_fields_from_class from onegov.form.validators import ( - FileSizeLimit, ValidPhoneNumber, ValidFilterFormDefinition) + FileSizeLimit, + ValidPhoneNumber, + ValidFilterFormDefinition, + MIME_TYPES_EXCEL +) from onegov.form.validators import WhitelistedMimeType from onegov.gis import CoordinatesField from onegov.org import _ @@ -537,19 +541,7 @@ class EventImportForm(Form): label=_('Import'), validators=[ DataRequired(), - WhitelistedMimeType({ - 'application/excel', - 'application/vnd.ms-excel', - ( - 'application/' - 'vnd.openxmlformats-officedocument.spreadsheetml.sheet' - ), - 'application/vnd.ms-office', - 'application/octet-stream', - 'application/zip', - 'text/csv', - 'text/plain', - }), + WhitelistedMimeType(MIME_TYPES_EXCEL), FileSizeLimit(10 * 1024 * 1024) ], render_kw={'force_simple': True} diff --git a/src/onegov/org/forms/parliamentarian.py b/src/onegov/org/forms/parliamentarian.py index b9ccf61ab5..39c44867ef 100644 --- a/src/onegov/org/forms/parliamentarian.py +++ b/src/onegov/org/forms/parliamentarian.py @@ -6,7 +6,11 @@ from onegov.form.fields import TranslatedSelectField from onegov.form.fields import UploadField from onegov.form.forms import NamedFileForm -from onegov.form.validators import ValidPhoneNumber +from onegov.form.validators import ( + MIME_TYPES_IMAGE, + ValidPhoneNumber, + WhitelistedMimeType +) from onegov.org import _ from onegov.parliament.models.parliamentarian import GENDERS from wtforms.fields import DateField @@ -49,6 +53,9 @@ class ParliamentarianForm(NamedFileForm): picture = UploadField( label=_('Picture'), fieldset=_('Basic properties'), + validators=[ + WhitelistedMimeType(MIME_TYPES_IMAGE) + ] ) party = StringField( diff --git a/src/onegov/org/models/file.py b/src/onegov/org/models/file.py index 8cf26214a5..d7a0153103 100644 --- a/src/onegov/org/models/file.py +++ b/src/onegov/org/models/file.py @@ -12,6 +12,7 @@ from onegov.file import File, FileSet, FileCollection, FileSetCollection from onegov.file import SearchableFile from onegov.file.utils import IMAGE_MIME_TYPES_AND_SVG +from onegov.form.validators import WhitelistedMimeType from onegov.org import _ from onegov.org.models.extensions import AccessExtension from onegov.org.utils import widest_access @@ -312,7 +313,7 @@ class GeneralFileCollection( GroupFilesByDateMixin[GeneralFile] ): - supported_content_types = 'all' + supported_content_types = WhitelistedMimeType.whitelist file_list = as_selectable(""" SELECT diff --git a/src/onegov/org/views/files.py b/src/onegov/org/views/files.py index f8a4208f81..caf2ed895e 100644 --- a/src/onegov/org/views/files.py +++ b/src/onegov/org/views/files.py @@ -442,10 +442,15 @@ def handle_file_upload( content=fs.file ) - supported_content_types = getattr(self, 'supported_content_types', 'all') + supported_content_types = self.supported_content_types # type:ignore[attr-defined] if supported_content_types != 'all': if file.reference.content_type not in supported_content_types: + # Fail the post request from upload.js with status code 415 + # (Unsupported Media Type). Raising the HTTP exception here causes + # the request transaction to abort and roll back any previous + # changes (including the `self.add(...)` above), so the file won't + # be persisted if the content type is unsupported. raise exc.HTTPUnsupportedMediaType() return file diff --git a/src/onegov/pas/forms/data_import.py b/src/onegov/pas/forms/data_import.py index e5de118dcd..1ec6b24926 100644 --- a/src/onegov/pas/forms/data_import.py +++ b/src/onegov/pas/forms/data_import.py @@ -4,6 +4,7 @@ from onegov.core.utils import dictionary_to_binary from onegov.form import Form from onegov.form.fields import UploadMultipleField +from onegov.form.validators import MIME_TYPES_JSON from onegov.pas import _ from onegov.pas.importer.json_import import ( MembershipData, @@ -24,6 +25,8 @@ class DataImportForm(Form): people_source = UploadMultipleField( label=_('People Data (JSON)'), description=_('JSON file containing parliamentarian data.'), + validators=[], # no validators as files are not stored + allowed_mimetypes=tuple(MIME_TYPES_JSON), ) organizations_source = UploadMultipleField( label=_('Organizations Data (JSON)'), @@ -31,6 +34,8 @@ class DataImportForm(Form): 'JSON file containing organization data (commissions, ' 'parties, etc.).' ), + validators=[], # no validators as files are not stored + allowed_mimetypes=tuple(MIME_TYPES_JSON), ) memberships_source = UploadMultipleField( label=_('Memberships Data (JSON)'), @@ -38,6 +43,8 @@ class DataImportForm(Form): 'JSON file containing membership data (who is member of ' 'what organization).' ), + validators=[], # no validators as files are not stored + allowed_mimetypes=tuple(MIME_TYPES_JSON), ) validate_schema = BooleanField( diff --git a/src/onegov/pas/forms/parliamentarian.py b/src/onegov/pas/forms/parliamentarian.py index 0f184186bc..4d0a85563f 100644 --- a/src/onegov/pas/forms/parliamentarian.py +++ b/src/onegov/pas/forms/parliamentarian.py @@ -4,7 +4,11 @@ from onegov.form.fields import TranslatedSelectField from onegov.form.fields import UploadField from onegov.form.forms import NamedFileForm -from onegov.form.validators import ValidPhoneNumber +from onegov.form.validators import ( + ValidPhoneNumber, + MIME_TYPES_IMAGE, + WhitelistedMimeType +) from onegov.parliament.models.parliamentarian import GENDERS from onegov.parliament.models.parliamentarian import SHIPPING_METHODS from onegov.pas.collections.parliamentarian import ( @@ -65,6 +69,9 @@ class PASParliamentarianForm(NamedFileForm): picture = UploadField( label=_('Picture'), fieldset=_('Basic properties'), + validators=[ + WhitelistedMimeType(MIME_TYPES_IMAGE), + ] ) shipping_method = TranslatedSelectField( diff --git a/src/onegov/pas/views/data_import.py b/src/onegov/pas/views/data_import.py index 25b16ee0f9..0445ddcd4e 100644 --- a/src/onegov/pas/views/data_import.py +++ b/src/onegov/pas/views/data_import.py @@ -95,6 +95,7 @@ def handle_data_import( error_message = None + # FIXME: why not use `form.submitted(request)` like in many places? if request.method == 'POST' and form.validate(): try: # Load and concatenate data from uploaded files diff --git a/src/onegov/town6/templates/files.pt b/src/onegov/town6/templates/files.pt index 334d010c4c..cea086a882 100644 --- a/src/onegov/town6/templates/files.pt +++ b/src/onegov/town6/templates/files.pt @@ -24,6 +24,9 @@ Upload Date + + Published until + diff --git a/src/onegov/town6/theme/styles/upload.scss b/src/onegov/town6/theme/styles/upload.scss index 40d4fbcacf..5a13e694fc 100644 --- a/src/onegov/town6/theme/styles/upload.scss +++ b/src/onegov/town6/theme/styles/upload.scss @@ -39,27 +39,58 @@ margin-bottom: 1.5rem; .progress { - margin-left: 25%; - width: 75%; + padding: 0.5rem; + background: #f6f6f6; + position: relative; + overflow: visible; - &::before { - content: attr(data-filename); - font-size: .875rem; - left: 1rem; + .progress-main { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .upload-filename { + flex: 0 0 auto; + font-size: 0.95rem; + color: #333; + max-width: 50%; + white-space: nowrap; overflow: hidden; - text-align: left; text-overflow: ellipsis; - white-space: nowrap; - width: 22.5%; } - &.alert::after { - color: $white; - content: attr(data-error); - font-size: .8rem; - margin-left: .75ex; - margin-top: -1.2rem; - position: absolute; + .meter { + display: inline-block; + height: 10px; + background: #0b7db1; + border-radius: 6px; + flex: 1 1 auto; + transition: width 150ms linear; + } + + .upload-error { + margin-top: 0.4rem; + color: #842029; + background: #f8d7da; + padding: 0.4rem 0.6rem; + font-size: 0.875rem; + display: none; + } + + &.has-error { + .meter { background: #b02a37; } + .upload-error { display: block; } + } + + // optional compact mobile layout + @media (max-width: 640px) { + .progress-main { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + .upload-filename { max-width: 100%; } } } -} +} \ No newline at end of file diff --git a/src/onegov/translator_directory/collections/documents.py b/src/onegov/translator_directory/collections/documents.py index 9fbcceba17..68ea1c7613 100644 --- a/src/onegov/translator_directory/collections/documents.py +++ b/src/onegov/translator_directory/collections/documents.py @@ -4,6 +4,7 @@ from itertools import groupby from onegov.file import File, FileCollection +from onegov.form.validators import WhitelistedMimeType from onegov.translator_directory.models.translator import Translator @@ -29,7 +30,7 @@ class TranslatorDocumentCollection(FileCollection[File]): - supported_content_types = 'all' + supported_content_types = WhitelistedMimeType.whitelist def __init__( self, diff --git a/src/onegov/translator_directory/forms/accreditation.py b/src/onegov/translator_directory/forms/accreditation.py index c54c2500ea..0c07f83c06 100644 --- a/src/onegov/translator_directory/forms/accreditation.py +++ b/src/onegov/translator_directory/forms/accreditation.py @@ -14,7 +14,7 @@ from onegov.form.fields import PanelField from onegov.form.fields import TagsField from onegov.form.fields import UploadField -from onegov.form.validators import FileSizeLimit +from onegov.form.validators import FileSizeLimit, MIME_TYPES_PDF from onegov.form.validators import Stdnum from onegov.form.validators import StrictOptional from onegov.form.validators import ValidPhoneNumber @@ -356,7 +356,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): declaration_of_authorization = UploadField( label=_('Signed declaration of authorization (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -367,7 +367,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): letter_of_motivation = UploadField( label=_('Short letter of motivation (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -378,7 +378,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): resume = UploadField( label=_('Resume (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -393,7 +393,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): 'level C2 are mandatory for non-native speakers.' ), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -404,7 +404,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): social_security_card = UploadField( label=_('Social security card (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -415,7 +415,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): passport = UploadField( label=_('Identity card, passport or foreigner identity card (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -426,7 +426,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): passport_photo = UploadField( label=_('Current passport photo (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -438,7 +438,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): label=_('Current extract from the debt collection register (PDF)'), description=_('Maximum 6 months since issue.'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -453,7 +453,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): 'www.strafregister.admin.ch' ), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -465,7 +465,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): label=_('Certificate of Capability (PDF)'), description=_('Available from the municipal or city administration.'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], @@ -479,7 +479,7 @@ class RequestAccreditationForm(Form, DrivingDistanceMixin): 'self-employment' ), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), DataRequired(), ], diff --git a/src/onegov/translator_directory/forms/mutation.py b/src/onegov/translator_directory/forms/mutation.py index f32560861b..052ae20b39 100644 --- a/src/onegov/translator_directory/forms/mutation.py +++ b/src/onegov/translator_directory/forms/mutation.py @@ -12,7 +12,7 @@ from onegov.form.fields import MultiCheckboxField from onegov.form.fields import TagsField from onegov.form.fields import UploadField -from onegov.form.validators import FileSizeLimit +from onegov.form.validators import FileSizeLimit, MIME_TYPES_PDF from onegov.form.validators import Stdnum from onegov.form.validators import ValidPhoneNumber from onegov.form.validators import ValidSwissSocialSecurityNumber @@ -256,7 +256,7 @@ def as_file(field: UploadField, category: str) -> File | None: declaration_of_authorization = UploadField( label=_('Signed declaration of authorization (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -267,7 +267,7 @@ def as_file(field: UploadField, category: str) -> File | None: letter_of_motivation = UploadField( label=_('Short letter of motivation (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -278,7 +278,7 @@ def as_file(field: UploadField, category: str) -> File | None: resume = UploadField( label=_('Resume (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -293,7 +293,7 @@ def as_file(field: UploadField, category: str) -> File | None: 'level C2 are mandatory for non-native speakers.' ), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -304,7 +304,7 @@ def as_file(field: UploadField, category: str) -> File | None: social_security_card = UploadField( label=_('Social security card (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -315,7 +315,7 @@ def as_file(field: UploadField, category: str) -> File | None: passport = UploadField( label=_('Identity card, passport or foreigner identity card (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -326,7 +326,7 @@ def as_file(field: UploadField, category: str) -> File | None: passport_photo = UploadField( label=_('Current passport photo (PDF)'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -338,7 +338,7 @@ def as_file(field: UploadField, category: str) -> File | None: label=_('Current extract from the debt collection register (PDF)'), description=_('Maximum 6 months since issue.'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -353,7 +353,7 @@ def as_file(field: UploadField, category: str) -> File | None: 'www.strafregister.admin.ch' ), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -365,7 +365,7 @@ def as_file(field: UploadField, category: str) -> File | None: label=_('Certificate of Capability (PDF)'), description=_('Available from the municipal or city administration.'), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], @@ -379,7 +379,7 @@ def as_file(field: UploadField, category: str) -> File | None: 'self-employment' ), validators=[ - WhitelistedMimeType({'application/pdf'}), + WhitelistedMimeType(MIME_TYPES_PDF), FileSizeLimit(100 * 1024 * 1024), Optional(), ], diff --git a/tests/onegov/form/test_fields.py b/tests/onegov/form/test_fields.py index d3dfc55984..483dfb2dce 100644 --- a/tests/onegov/form/test_fields.py +++ b/tests/onegov/form/test_fields.py @@ -6,6 +6,7 @@ from cgi import FieldStorage from copy import deepcopy from datetime import datetime + from onegov.core.utils import Bunch from onegov.core.utils import dictionary_to_binary from onegov.form import Form @@ -22,15 +23,36 @@ from onegov.form.fields import UploadField from onegov.form.fields import UploadMultipleField from onegov.form.fields import URLField -from onegov.form.validators import ValidPhoneNumber +from onegov.form.validators import ( + ValidPhoneNumber, WhitelistedMimeType, MIME_TYPES_JSON, MIME_TYPES_PDF) from unittest.mock import patch +from wtforms import FileField, Field from wtforms.validators import Optional from wtforms.validators import URL -from typing import Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Self, Sequence + if TYPE_CHECKING: from webob.request import _FieldStorageWithFile + from onegov.form.types import ( + FormT, Validators, Validator) + + +def assert_whitelisted_mimetype_validator( + field: UploadField | UploadMultipleField +) -> None: + + validator = find_validator(field, WhitelistedMimeType) + assert validator + assert validator.whitelist == WhitelistedMimeType.whitelist # type:ignore[attr-defined] + + +def find_validator( + field: Field | FileField, + cls: type +) -> Validator[Any, Any] | None: + return next((v for v in field.validators if isinstance(v, cls)), None) class DummyPostData(dict[str, Any]): @@ -56,9 +78,15 @@ def create_file( def test_upload_field() -> None: - def create_field() -> tuple[Form, UploadField]: + def create_field( + validators: Validators[FormT, Self] | None = None, # type:ignore[misc] + allowed_mimetypes: Sequence[str] | None = None, + ) -> tuple[Form, UploadField]: form = Form() - field = UploadField() + field = UploadField( + validators=validators, + allowed_mimetypes=allowed_mimetypes, + ) field = field.bind(form, 'upload') # type: ignore[attr-defined] return form, field @@ -68,12 +96,16 @@ def create_field() -> tuple[Form, UploadField]: assert data == {} assert field.file is None assert field.filename is None + assert field.validate(form) + assert_whitelisted_mimetype_validator(field) form, field = create_field() data = field.process_fieldstorage('') assert data == {} assert field.file is None assert field.filename is None + assert field.validate(form) + assert_whitelisted_mimetype_validator(field) textfile = create_file('text/plain', 'foo.txt', b'foo') data = field.process_fieldstorage(textfile) @@ -84,6 +116,8 @@ def create_field() -> tuple[Form, UploadField]: assert dictionary_to_binary(data) == b'foo' # type: ignore[arg-type] assert field.filename == 'foo.txt' assert field.file.read() == b'foo' # type: ignore[attr-defined] + assert field.validate(form) + assert_whitelisted_mimetype_validator(field) form, field = create_field() textfile = create_file('text/plain', 'C:/mydata/bar.txt', b'bar') @@ -95,6 +129,20 @@ def create_field() -> tuple[Form, UploadField]: assert dictionary_to_binary(data) == b'bar' # type: ignore[arg-type] assert field.filename == 'bar.txt' assert field.file.read() == b'bar' # type: ignore[union-attr] + assert field.validate(form) + assert_whitelisted_mimetype_validator(field) + + # failing mime type validator + form, field = create_field(allowed_mimetypes=tuple(MIME_TYPES_PDF)) + textfile = create_file('text/plain', 'baz.txt', b'baz') + data = field.data = field.process_fieldstorage(textfile) + assert data['filename'] == 'baz.txt' + assert data['mimetype'] == 'text/plain' + validator = find_validator(field, WhitelistedMimeType) + assert validator + assert validator.whitelist == ('application/pdf',) # type:ignore[attr-defined] + assert not field.validate(form) + assert 'Files of this type are not supported.' in field.errors # Test rendering form, field = create_field() @@ -104,6 +152,7 @@ def create_field() -> tuple[Form, UploadField]: field.data = field.process_fieldstorage(textfile) assert 'without-data' in field(force_simple=True) + assert_whitelisted_mimetype_validator(field) html = field() assert 'with-data' in html @@ -111,6 +160,7 @@ def create_field() -> tuple[Form, UploadField]: assert 'keep' in html assert 'type="file"' in html assert 'value="baz.txt"' not in html + assert_whitelisted_mimetype_validator(field) html = field(resend_upload=True) assert 'with-data' in html @@ -118,12 +168,14 @@ def create_field() -> tuple[Form, UploadField]: assert 'keep' in html assert 'type="file"' in html assert 'value="baz.txt"' in html + assert_whitelisted_mimetype_validator(field) # Test submit form, field = create_field() field.process(DummyPostData({})) assert field.validate(form) assert field.data == {} + assert_whitelisted_mimetype_validator(field) form, field = create_field() field.process(DummyPostData({'upload': 'abcd'})) @@ -132,6 +184,7 @@ def create_field() -> tuple[Form, UploadField]: assert field.data == {} assert field.file is None assert field.filename is None + assert_whitelisted_mimetype_validator(field) # ... simple form, field = create_field() @@ -146,6 +199,7 @@ def create_field() -> tuple[Form, UploadField]: assert dictionary_to_binary(field.data) == b'foobar' # type: ignore[arg-type] assert field.filename == 'foobar.txt' assert field.file.read() == b'foobar' # type: ignore[union-attr] + assert_whitelisted_mimetype_validator(field) # ... with select form, field = create_field() @@ -153,6 +207,7 @@ def create_field() -> tuple[Form, UploadField]: field.process(DummyPostData({'upload': ['keep', textfile]})) assert field.validate(form) assert field.action == 'keep' + assert_whitelisted_mimetype_validator(field) form, field = create_field() textfile = create_file('text/plain', 'foobar.txt', b'foobar') @@ -160,6 +215,7 @@ def create_field() -> tuple[Form, UploadField]: assert field.validate(form) assert field.action == 'delete' assert field.data == {} + assert_whitelisted_mimetype_validator(field) form, field = create_field() textfile = create_file('text/plain', 'foobar.txt', b'foobar') @@ -173,6 +229,7 @@ def create_field() -> tuple[Form, UploadField]: assert dictionary_to_binary(field.data) == b'foobar' # type: ignore[arg-type] assert field.filename == 'foobar.txt' assert field.file.read() == b'foobar' # type: ignore[union-attr] + assert_whitelisted_mimetype_validator(field) # ... with select and keep upload previous = field.data @@ -191,6 +248,7 @@ def create_field() -> tuple[Form, UploadField]: assert field.data['mimetype'] == 'text/plain' assert field.data['size'] == 6 assert dictionary_to_binary(field.data) == b'foobar' # type: ignore[arg-type] + assert_whitelisted_mimetype_validator(field) field.process(DummyPostData({'upload': [ 'delete', @@ -203,6 +261,7 @@ def create_field() -> tuple[Form, UploadField]: assert field2.validate(form) assert field2.action == 'delete' assert field2.data == {} + assert_whitelisted_mimetype_validator(field) field.process(DummyPostData({'upload': [ 'replace', @@ -221,15 +280,35 @@ def create_field() -> tuple[Form, UploadField]: assert dictionary_to_binary(field2.data) == b'foobaz' # type: ignore[arg-type] assert field2.filename == 'foobaz.txt' assert field2.file.read() == b'foobaz' # type: ignore[union-attr] + assert_whitelisted_mimetype_validator(field) def test_upload_multiple_field() -> None: - def create_field() -> tuple[Form, UploadMultipleField]: + def create_field( + validators: Validators[FormT, Self] | None = None, # type:ignore[misc] + allowed_mimetypes: Sequence[str] | None = None, + ) -> tuple[Form, UploadMultipleField]: form = Form() - field = UploadMultipleField() + field = UploadMultipleField( + validators=validators, + allowed_mimetypes=allowed_mimetypes, + ) field = field.bind(form, 'uploads') # type: ignore[attr-defined] return form, field + # failing mime type validator + form, field = create_field(allowed_mimetypes=tuple(MIME_TYPES_JSON)) + file1 = create_file('text/plain', 'baz.txt', b'baz') + field.process(DummyPostData({'uploads': [file1]})) + assert not field.validate(form) + assert len(field.data) == 1 + assert field.data[0]['filename'] == 'baz.txt' + assert field.data[0]['mimetype'] == 'text/plain' + for subfield in field: + validator = find_validator(subfield, WhitelistedMimeType) + assert validator + assert validator.whitelist == ('application/json',) # type:ignore[attr-defined] + # Test rendering and initial submit form, field = create_field() field.process(None) @@ -245,6 +324,7 @@ def create_field() -> tuple[Form, UploadMultipleField]: file1 = create_file('text/plain', 'baz.txt', b'baz') file2 = create_file('text/plain', 'foobar.txt', b'foobar') field.process(DummyPostData({'uploads': [file1, file2]})) + assert field.validate(form) assert len(field.data) == 2 assert field.data[0]['filename'] == 'baz.txt' assert field.data[0]['mimetype'] == 'text/plain' @@ -265,8 +345,11 @@ def create_field() -> tuple[Form, UploadMultipleField]: assert dictionary_to_binary(file_field2.data) == b'foobar' # type: ignore[arg-type] assert file_field2.filename == 'foobar.txt' assert file_field2.file.read() == b'foobar' # type: ignore[union-attr] + for subfield in field: + assert_whitelisted_mimetype_validator(subfield) html = field(force_simple=True) + assert field.validate(form) assert 'without-data' in html assert 'multiple' in html assert 'name="uploads"' in html @@ -274,6 +357,7 @@ def create_field() -> tuple[Form, UploadMultipleField]: assert 'name="uploads-0"' not in html html = field() + assert field.validate(form) assert 'with-data' in html assert 'name="uploads-0"' in html assert 'Uploaded file: baz.txt (3 Bytes) ✓' in html @@ -290,6 +374,7 @@ def create_field() -> tuple[Form, UploadMultipleField]: assert 'multiple' in html html = field(resend_upload=True) + assert field.validate(form) assert 'with-data' in html assert 'Uploaded file: baz.txt (3 Bytes) ✓' in html assert 'Uploaded file: foobar.txt (6 Bytes) ✓' in html diff --git a/tests/onegov/form/test_parser.py b/tests/onegov/form/test_parser.py index 003314888e..5d48f6c912 100644 --- a/tests/onegov/form/test_parser.py +++ b/tests/onegov/form/test_parser.py @@ -4,6 +4,9 @@ from dateutil.relativedelta import relativedelta from decimal import Decimal + +from wtforms import Field + from onegov.form import Form, errors, find_field from onegov.form import parse_formcode, parse_form, flatten_fieldsets from onegov.form.errors import ( @@ -12,10 +15,20 @@ InvalidCommentLocationSyntax, ) from onegov.form.fields import ( - DateTimeLocalField, MultiCheckboxField, TimeField, URLField, VideoURLField) + DateTimeLocalField, + MultiCheckboxField, + TimeField, + URLField, + VideoURLField, +) from onegov.form.parser.grammar import field_help_identifier -from onegov.form.validators import LaxDataRequired -from onegov.form.validators import ValidDateRange +from onegov.form.validators import ( + LaxDataRequired, + WhitelistedMimeType, + FileSizeLimit, + StrictOptional, + ValidDateRange +) from onegov.pay import Price from textwrap import dedent from webob.multidict import MultiDict @@ -28,16 +41,25 @@ from wtforms.validators import Optional from wtforms.validators import Regexp +from typing import TYPE_CHECKING, Any -from typing import TYPE_CHECKING if TYPE_CHECKING: from pyparsing import ParserElement, ParseResults + from onegov.form.types import Validator + def parse(expr: ParserElement, text: str) -> ParseResults: return expr.parseString(text) +def find_validator( + field: Field | FileField, + cls: type +) -> Validator[Any, Any] | None: + return next((v for v in field.validators if isinstance(v, cls)), None) + + @pytest.mark.parametrize('comment,output', [ ('<< Some text >>', 'Some text'), ('<< [Z](www.co.me) >>', '[Z](www.co.me)') @@ -345,6 +367,27 @@ def test_parse_fileinput() -> None: assert isinstance(form['file'], FileField) assert form['file'].widget.multiple is False # type: ignore[attr-defined] + # verify attached mime type validator + assert form['file'].validators + validator = find_validator(form['file'], WhitelistedMimeType) + assert validator + assert validator.whitelist == { # type:ignore[attr-defined] + 'application/msword', 'application/pdf' + } + assert find_validator(form['file'], FileSizeLimit) + + form = parse_form("File *= *.*")() + assert form['file'].validators + validator = find_validator(form['file'], WhitelistedMimeType) + assert validator + assert validator.whitelist == WhitelistedMimeType.whitelist # type:ignore[attr-defined] + assert find_validator(form['file'], FileSizeLimit) + + # ensure nickname field did not get validators from the upload field + form = parse_form("Nickname = ___\nFile = *.pdf|*.doc")() + assert find_validator(form['nickname'], StrictOptional) + assert not find_validator(form['nickname'], WhitelistedMimeType) + def test_parse_multiplefileinput() -> None: form = parse_form("Files = *.pdf|*.doc (multiple)")() @@ -353,9 +396,24 @@ def test_parse_multiplefileinput() -> None: assert isinstance(form['files'], FileField) assert form['files'].widget.multiple is True # type: ignore[attr-defined] + # verify attached mime type validator + assert form['files'].validators + validator = find_validator(form['files'], WhitelistedMimeType) + assert validator + assert validator.whitelist == { # type:ignore[attr-defined] + 'application/msword', 'application/pdf' + } + assert find_validator(form['files'], FileSizeLimit) + + form = parse_form("My files *= *.* (multiple)")() + assert form['my_files'].validators + validator = find_validator(form['my_files'], WhitelistedMimeType) + assert validator + assert validator.whitelist == WhitelistedMimeType.whitelist # type:ignore[attr-defined] + assert find_validator(form['my_files'], FileSizeLimit) -def test_parse_radio() -> None: +def test_parse_radio() -> None: text = dedent(""" Gender = ( ) Male @@ -543,6 +601,59 @@ def test_dependent_validation() -> None: ) ) + # dependency with upload field + text = dedent(""" + method *= + (x) Drop off + Estimated drop off time = HH:MM + ( ) Upload + Attachment *= *.pdf|*.doc + """) + + form_class = parse_form(text) + + # drop off: no file required + form = form_class(MultiDict([('method', 'Drop off')])) + assert form.validate() + assert not form.errors + + # upload selected but no file provided + form = form_class(MultiDict([('method', 'Upload')])) + assert not form.validate() + assert form.errors == {'method_attachment': ['This field is required.']} + + # upload selected with large attachment and wrong type + from io import BytesIO + from werkzeug.datastructures import FileStorage + + fs_text = FileStorage( + stream=BytesIO(b'Hello, this is plain text\n' + + b'A' * 101 * 1000 ** 2), + filename='test.txt', + content_type='text/plain' + ) + form = form_class( + MultiDict([('method', 'Upload'), ('method_attachment', fs_text)])) + assert not form.validate() + assert form.errors == { + 'method_attachment': [ + 'Files of this type are not supported.', + 'The file is too large, please provide a file smaller ' + 'than 100.0 MB.' + ] + } + + # upload selected with large attachment and wrong type + fs_pdf = FileStorage( + stream=BytesIO(b'%PDF-1.4\n%'), + filename='test.pdf', + content_type='application/pdf' + ) + form = form_class( + MultiDict([('method', 'Upload'), ('method_attachment', fs_pdf)])) + assert form.validate() + assert not form.errors + def test_nested_regression() -> None: diff --git a/tests/onegov/pas/test_views.py b/tests/onegov/pas/test_views.py index e1bc2c6ff1..f03a349972 100644 --- a/tests/onegov/pas/test_views.py +++ b/tests/onegov/pas/test_views.py @@ -382,7 +382,6 @@ def do_upload_procedure( result = page.form.submit().maybe_follow() # Add assertions as needed - assert result.status_code == 200 assert result.status_code == 200, f"Import failed: {result.text}" return result