From 7e7f9d556efa11939542f3704fefa3f4039b6fb7 Mon Sep 17 00:00:00 2001 From: Carsten Rose <carsten.rose@math.uzh.ch> Date: Sat, 26 Jan 2019 00:56:06 +0100 Subject: [PATCH] Implements and fixes #7747. Import Excel: restrict reading to explicit named worksheets --- extension/Documentation/Manual.rst | 110 ++++++++++++++++------------ extension/Source/core/Constants.php | 8 ++ extension/Source/core/Save.php | 49 +++++++++++-- extension/Source/core/sample.xlsx | Bin 4797 -> 0 bytes 4 files changed, 113 insertions(+), 54 deletions(-) delete mode 100644 extension/Source/core/sample.xlsx diff --git a/extension/Documentation/Manual.rst b/extension/Documentation/Manual.rst index 31ac84e52..d7bed9b16 100644 --- a/extension/Documentation/Manual.rst +++ b/extension/Documentation/Manual.rst @@ -2790,31 +2790,39 @@ See also at specific *FormElement* definitions. +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | autofocus | string | See `input-option-autofocus`_ | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ -| capture | string | See `input-upload`_ | -| accept | string | | -| maxFileSize | string | | -| fileDestination | string | | -| fileReplace | string | | -| autoOrient | string | | -| autoOrientCmd | string | | -| autoOrientMimeType | string | | -| chmodFile / chmodDir | string | | -| slaveId | string | | -| sqlBefore | string | | -| sqlInsert | string | | -| sqlUpdate | string | | -| sqlDelete | string | | -| sqlAfter | string | | +| capture, | string | See `input-upload`_ | +| accept, | | | +| maxFileSize, | | | +| fileDestination, | | | +| fileReplace, | | | +| autoOrient, | | | +| autoOrientCmd, | | | +| autoOrientMimeType, | | | +| chmodFile, chmodDir, | | | +| slaveId, | | | +| sqlBefore, | | | +| sqlInsert, | | | +| sqlUpdate, | | | +| sqlDelete, | | | +| sqlAfter, | | | +| importToTable, | | | +| importToColumns, | | | +| importRegion, | | | +| importMode, | | | +| importType, | | | +| importNamedSheetsOnly, | | | +| importSetReadDataOnly, | | | +| importListSheetNames, | | | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | checkBoxMode | string | See `input-checkbox`_, `input-radio`_, `input-select`_ | -| checked | string | | -| unchecked | string | | -| label2 | string | | -| itemList | string | | -| emptyHide | - | | -| emptyItemAtStart | - | | -| emptyItemAtEnd | - | | -| buttonClass | string | | +| checked | | | +| unchecked | | | +| label2 | | | +| itemList | | | +| emptyHide | | | +| emptyItemAtStart | | | +| emptyItemAtEnd | | | +| buttonClass | | | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | dateFormat | string | yyyy-mm-dd / dd.mm.yyyy | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ @@ -2841,20 +2849,20 @@ See also at specific *FormElement* definitions. +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | extraButtonInfoClass | string | By default empty. Specify any class to be assigned to wrap extraButtonInfo | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ -| editor-plugins | string | See `input-editor`_ | -| editor-toolbar | string | | -| editor-statusbar | string | | +| editor-plugins, | string | See `input-editor`_ | +| editor-toolbar, | | | +| editor-statusbar, | | | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | fileButtonText | string | Overwrite default 'Choose File' | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | fillStoreVar | string | Fill the STORE_VAR with custom values. See `STORE_VARS`_. | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ -| form | string | See `subrecord-option`_ | -| page | string | | -| title | string | | -| extraDeleteForm | string | | -| detail | string | | -| subrecordTableClass | string | | +| form, | string | See `subrecord-option`_ | +| page, | | | +| title, | | | +| extraDeleteForm, | | | +| detail, | | | +| subrecordTableClass, | | | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | min | s/d/n | Minimum and/or maximum allowed values for input field. Can be used for numbers, dates, or strings. | +------------------------+--------+ | @@ -2863,12 +2871,12 @@ See also at specific *FormElement* definitions. | processReadOnly | [n] | [0|1] By default FE's with type='readonly' are not processed during 'save'. | | | | This option forces to process them during 'save' as well. | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ -| retype | string | See `input-text`_ | -| retypeLabel | string | | -| retypeNote | string | | -| characterCountWrap | string | | -| hideZero | string | | -| emptyMeansNull | string | | +| retype, | string | See `input-text`_ | +| retypeLabel, | | | +| retypeNote, | | | +| characterCountWrap, | | | +| hideZero, | | | +| emptyMeansNull, | | | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | showSeconds | string | 0|1 - Shows the seconds on form load. Default: 0 | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ @@ -2877,10 +2885,10 @@ See also at specific *FormElement* definitions. | timeIsOptional | string | 0|1 - Used for datetime input. 0 (default): Time is required - 1: Entering a time is optional | | | | (defaults to 00:00:00 if none entered). | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ -| typeAheadLimit | string | See `input-typeahead`_ | -| typeAheadMinLength | string | | -| typeAheadSql | string | | -| typeAheadSqlPrefetch | string | | +| typeAheadLimit, | string | See `input-typeahead`_ | +| typeAheadMinLength, | | | +| typeAheadSql, | | | +| typeAheadSqlPrefetch, | | | +------------------------+--------+----------------------------------------------------------------------------------------------------------+ | wrapRow | string | If specified, skip default wrapping (`<div class='col-md-?'>`). Instead the given string is used. | +------------------------+--------+ | @@ -3606,16 +3614,22 @@ See also `downloadButton`_ to offer a download of an uploaded file. (e.g. 43214), which is the serial value date in Excel. To convert such a number to a MariaDb date, use: `DATE_ADD('1899-12-30', INTERVAL serialValue DAY)`. - * *importToTable*: <mariadb.tablename> - **Required**. Providing this parameter activates the import. If the table + * *importToTable* = <[db.]tablename> - **Required**. Providing this parameter activates the import. If the table doesn't exist, it will be created. - * *importToColumns*: <col1>,<col2>,... - If none provided, the Excel column names A, B, ... are used. Note: These + * *importToColumns* = <col1>,<col2>,... - If none provided, the Excel column names A, B, ... are used. Note: These have to match the table's column names if the table already exists. - * *importRegion*: [tab],[startColumn],[startRow],[endColumn],[endRow]|... - All parts are optional (default: + * *importRegion* = [tab],[startColumn],[startRow],[endColumn],[endRow]|... - All parts are optional (default: entire 1st sheet). Tab can either be given as an index (1-based) or a name. start/endColumn can be given either numerically (1, 2, ...) or by column name (A, B, ...). Note that you can specify several regions to import. - * *importMode*: `append` (default) | `replace` - The data is either appended or replace in the specified table. - * *importType*: `auto` (default) | `xls` | `xlsx` | `ods` | `csv` - Define what kind of data should be expected by the - Spreadsheet Reader. `auto` should work fine in most cases. + * *importMode* = `append` (default) | `replace` - The data is either appended or replace in the specified table. + * *importType* = `auto` (default) | `xls` | `xlsx` | `ods` | `csv` - Define what kind of data should be expected by the + Spreadsheet Reader. + * *importNamedSheetsOnly* = <comma separated list of sheet names>. Use this option if specific sheets cause problems + during import and should be skipped, by naming only those sheets, who will be read. This will also reduce the memory + usage. + * *importSetReadDataOnly* = 0|1. Read only cell data, not the cell formatting. Warning: cell types other than numerical + will be misinterpreted. + * *importListSheetNames* = 0|1. For debug use only. Will open a dialog and report all found worksheet names. Immediately after the upload finished (before the user press save), the file will be checked on the server for it's diff --git a/extension/Source/core/Constants.php b/extension/Source/core/Constants.php index 870bb398f..cacf6905e 100644 --- a/extension/Source/core/Constants.php +++ b/extension/Source/core/Constants.php @@ -337,6 +337,10 @@ const ERROR_DND_EMPTY_REORDER_SQL = 2700; // Form const ERROR_FORM_RESERVED_NAME = 2800; +// Import (Excel, ODS, ...) +const ERROR_IMPORT_MISSING_EXPLICIT_TYPE = 2900; +const ERROR_IMPORT_LIST_SHEET_NAMES = 2901; + // // Store Names: Identifier // @@ -1040,6 +1044,10 @@ const FE_IMPORT_TYPE_XLS = 'xls'; const FE_IMPORT_TYPE_XLSX = 'xlsx'; const FE_IMPORT_TYPE_ODS = 'ods'; const FE_IMPORT_TYPE_CSV = 'csv'; +const FE_IMPORT_NAMED_SHEETS_ONLY = 'importNamedSheetsOnly'; +const FE_IMPORT_READ_DATA_ONLY = 'importSetReadDataOnly'; +const FE_IMPORT_LIST_SHEET_NAMES = 'importListSheetNames'; + const FE_IMAGE_SOURCE = 'imageSource'; // Image source for a fabric element const FE_DEFAULT_PEN_COLOR = 'defaultPenColor'; // Default pen color for a fabric element diff --git a/extension/Source/core/Save.php b/extension/Source/core/Save.php index bb9fba4c9..a578d4a75 100644 --- a/extension/Source/core/Save.php +++ b/extension/Source/core/Save.php @@ -369,7 +369,7 @@ class Save { } $column = $formElement[FE_NAME]; - $pathFileName = $this->doUpload($formElement, ($formValues[$column]??''), $sip, $modeUpload); + $pathFileName = $this->doUpload($formElement, ($formValues[$column] ?? ''), $sip, $modeUpload); if ($modeUpload == UPLOAD_MODE_DELETEOLD && $pathFileName == '') { $pathFileNameTmp = ''; // see '4' @@ -661,9 +661,25 @@ class Save { * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception */ private function doImport($formElement, $fileName) { + $importNamedSheetsOnly = array(); Support::setIfNotSet($formElement, FE_IMPORT_TYPE, FE_IMPORT_TYPE_AUTO); + if (!empty($formElement[FE_IMPORT_NAMED_SHEETS_ONLY])) { + $importNamedSheetsOnly = explode(',', $formElement[FE_IMPORT_NAMED_SHEETS_ONLY]); + } + + if ($formElement[FE_IMPORT_TYPE] === FE_IMPORT_TYPE_AUTO) { + + $list = [FE_IMPORT_LIST_SHEET_NAMES, FE_IMPORT_READ_DATA_ONLY, FE_IMPORT_LIST_SHEET_NAMES]; + foreach ($list as $token) { + if (isset($formElement[$token])) { + throw new UserFormException('If ' . $token . + ' is given, an explicit document type (like ' . FE_IMPORT_TYPE . '=xlsx) should be set.', ERROR_IMPORT_MISSING_EXPLICIT_TYPE); + } + } + } + switch ($formElement[FE_IMPORT_TYPE]) { case FE_IMPORT_TYPE_AUTO: $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($fileName); @@ -675,6 +691,23 @@ class Save { case FE_IMPORT_TYPE_ODS: $inputFileType = ucfirst($formElement[FE_IMPORT_TYPE]); $reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType); + + // setReadDataOnly + if (($formElement[FE_IMPORT_READ_DATA_ONLY] ?? '0') != '0') { + $reader->setReadDataOnly(true); + } + + // setLoadSheetsOnly + if (!empty ($importNamedSheetsOnly)) { + $reader->setLoadSheetsOnly($importNamedSheetsOnly); + } + + if (($formElement[FE_IMPORT_LIST_SHEET_NAMES] ?? '0') != '0') { + $sheetNames = $reader->listWorksheetNames($fileName); + throw new UserFormException("Worksheets: " . implode(', ', $sheetNames), + ERROR_IMPORT_LIST_SHEET_NAMES); + } + $spreadsheet = $reader->load($fileName); break; @@ -685,7 +718,7 @@ class Save { $tableName = $formElement[FE_IMPORT_TO_TABLE]; $regions = OnArray::trimArray(explode('|', $formElement[FE_IMPORT_REGION] ?? '')); - $columnNames = OnArray::trimArray(explode(',', $formElement[FE_IMPORT_TO_COLUMNS])); + $columnNames = OnArray::trimArray(explode(',', $formElement[FE_IMPORT_TO_COLUMNS] ?? '')); $importMode = $formElement[FE_IMPORT_MODE] ?? FE_IMPORT_MODE_APPEND; foreach ($regions as $region) { @@ -789,7 +822,8 @@ class Save { * @throws UserFormException * @throws UserReportException */ - private function copyUploadFile(array $formElement, array $statusUpload) { + private + function copyUploadFile(array $formElement, array $statusUpload) { $pathFileName = ''; if (!isset($statusUpload[FILES_TMP_NAME]) || $statusUpload[FILES_TMP_NAME] === '') { @@ -852,7 +886,8 @@ class Save { * @throws UserFormException * @throws UserReportException */ - private function autoOrient(array $formElement, $pathFileName) { + private + function autoOrient(array $formElement, $pathFileName) { // 'autoOrient' wished? if (!isset($formElement[FE_FILE_AUTO_ORIENT]) || $formElement[FE_FILE_AUTO_ORIENT] == '0') { @@ -893,7 +928,8 @@ class Save { * @throws UserFormException * @throws UserReportException */ - private function splitUpload(array $formElement, $pathFileName, $chmod, array $statusUpload) { + private + function splitUpload(array $formElement, $pathFileName, $chmod, array $statusUpload) { if (empty($formElement[FE_FILE_SPLIT]) || $statusUpload[FILES_TYPE] != MIME_TYPE_SPLIT_CAPABLE) { return; @@ -1016,7 +1052,8 @@ class Save { * @throws UserFormException * @throws UserReportException */ - private function doUploadSlave(array $fe, $modeUpload) { + private + function doUploadSlave(array $fe, $modeUpload) { $sql = ''; $flagUpdateSlaveId = false; $flagSlaveDeleted = false; diff --git a/extension/Source/core/sample.xlsx b/extension/Source/core/sample.xlsx deleted file mode 100644 index bbcfe0f7bb0c9217fb6114a4177af6cc83fec086..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4797 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 -- GitLab