From 23de5a85c21c57a5cd9c633c1ac8ede8b02ef805 Mon Sep 17 00:00:00 2001 From: Serge Latyntcev Date: Tue, 31 Mar 2020 15:54:36 +1300 Subject: [PATCH 1/4] [CVE-2020-9280] Task for shifting UserForm uploads into correct folders A task helper for recovering UserForm uploads targeting incorrectly migrated folders (from Silverstripe CMS 3) If your project has not been migrated from Silverstripe CMS 3, you do not need this helper. Before running this task make sure you have repaired the migrated folders themselves. To do that you have to run the extra migration subtask (`migrate-folders`). This task is particularly looking at all UserForm file submissions and checks they are in the same folder where the particular version of its EditableFileField has been set up to upload it to. If it finds the file has been misplaced, it tries to move it to the correct folder, but only if the file has not had any manipulations since the uploading happened (the file Version=1). If an affected file has a draft, then only Live version will be moved, but the draft will be preserved as is. For more details see CVE-2020-9280 --- code/Task/RecoverUploadLocationsHelper.php | 570 +++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 code/Task/RecoverUploadLocationsHelper.php diff --git a/code/Task/RecoverUploadLocationsHelper.php b/code/Task/RecoverUploadLocationsHelper.php new file mode 100644 index 0000000..2b0e8a2 --- /dev/null +++ b/code/Task/RecoverUploadLocationsHelper.php @@ -0,0 +1,570 @@ + '%$' . LoggerInterface::class . '.quiet', + ]; + + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var Versioned + */ + private $versionedExtension; + + /** + * Whether File class has Versioned extension installed + * + * @var bool + */ + private $filesVersioned; + + /** + * Cache of the EditableFileField versions + * + * @var EditableFileField + */ + private $fieldFolderCache = array(); + + public function __construct() + { + $this->logger = new NullLogger(); + + // Set up things before going into the loop + $this->versionedExtension = Injector::inst()->get(Versioned::class); + $this->filesVersioned = $this->versionedExtension->canBeVersioned(File::class); + } + + /** + * @param LoggerInterface $logger + * @return $this + */ + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + return $this; + } + + /** + * Process the UserForm uplodas + * + * @return int Number of files processed + */ + public function run() + { + // Set max time and memory limit + Environment::increaseTimeLimitTo(); + Environment::setMemoryLimitMax(-1); + Environment::increaseMemoryLimitTo(-1); + + $this->logger->notice('Begin UserForm uploaded files destination folders recovery'); + + if (!class_exists(Versioned::class)) { + $this->logger->warning('Versioned extension is not installed. Skipping recovery.'); + return 0; + } + + if (!$this->versionedExtension->canBeVersioned(UserDefinedForm::class)) { + $this->logger->warning('Versioned extension is not set up for UserForms. Skipping recovery.'); + return 0; + } + + return $this->process(); + } + + /** + * Process all the files and return the number + * + * @return int Number of files processed + */ + protected function process() + { + // Check if we have folders to migrate + $totalCount = $this->getCountQuery()->count(); + if (!$totalCount) { + $this->logger->warning('No UserForm uploads found'); + return 0; + } + + $this->logger->notice(sprintf('Processing %d file records', $totalCount)); + + $processedCount = 0; + $recoveryCount = 0; + $errorsCount = 0; + + // Loop over the files to process + foreach($this->chunk() as $uploadRecord) { + ++$processedCount; + + $fileId = $uploadRecord['UploadedFileID']; + $fieldId = $uploadRecord['FieldID']; + $fieldVersion = $uploadRecord['FieldVersion']; + + try { + $expectedFolderId = $this->getExpectedUploadFolderId($fieldId, $fieldVersion); + if ($expectedFolderId == 0) { + $this->logger->warning(sprintf( + 'The upload folder was not set for the file %d, SKIPPING', + $fileId + )); + continue; + } + $recoveryCount += $this->recover($fileId, $expectedFolderId); + } catch (\Exception $e) { + $this->logger->error(sprintf('Could not process the file: %d', $fileId), ['exception' => $e]); + ++$errorsCount; + } + } + + // Show summary of results + if ($processedCount > 0) { + $this->logger->notice(sprintf('%d file records have been processed.', $processedCount)); + $this->logger->notice(sprintf('%d files recovered', $recoveryCount)); + $this->logger->notice(sprintf('%d errors', $errorsCount)); + } else { + $this->logger->notice('No files found'); + } + + return $processedCount; + } + + /** + * Fetches the EditableFileField version from cache and returns its FolderID + * + * @param int $fieldId EditableFileField.ID + * @param int EditableFileField Version + * + * @return int + */ + protected function getExpectedUploadFolderId($fieldId, $fieldVersion) + { + // return if cache is warm + if (isset($this->fieldFolderCache[$fieldId][$fieldVersion])) { + return $this->fieldFolderCache[$fieldId][$fieldVersion]->FolderID; + } + + // fetch the version + $editableFileField = Versioned::get_version(EditableFileField::class, $fieldId, $fieldVersion); + + // populate the cache + $this->fieldFolderCache[$fieldId][$fieldVersion] = $editableFileField; + + return $editableFileField->FolderID; + } + + /** + * Fetches a Folder by its ID, gracefully handling + * deleted folders + * + * @param int $id Folder.ID + * + * @return Folder + * + * @throws RuntimeException when folder could not be found + */ + protected function getFolder($id) + { + $folder = Folder::get()->byID($id); + + if (!$folder && $this->filesVersioned) { + // The folder might have been deleted, let's look up its latest version + $folder = Versioned::get_latest_version(Folder::class, $id); + + if ($folder) { + $this->logger->warning(sprintf('Restoring (as protected) a deleted folder: "%s"', $folder->Filename)); + if ($folder->CanViewType === InheritedPermissions::INHERIT) { + // enforce restored top level folders to be protected + $folder->CanViewType = InheritedPermissions::ONLY_THESE_USERS; + } + + $folder->publishSingle(); + } + } + + if (!$folder) { + throw new RuntimeException(sprintf('Could not fetch the folder with id "%d"', $id)); + } + + return $folder; + } + + /** + * Recover an uploaded file location + * + * @param int $fileId File.ID + * @param int $expectedFolderId ID of the folder where the file should have end up + * + * @return int Number of files recovered + */ + protected function recover($fileId, $expectedFolderId) + { + /* @var File */ + $draft = null; + + /* @var File */ + $live = null; + + if ($this->filesVersioned) { + $draftVersion = Versioned::get_versionnumber_by_stage(File::class, Versioned::DRAFT, $fileId); + $liveVersion = Versioned::get_versionnumber_by_stage(File::class, Versioned::LIVE, $fileId);; + + if ($draftVersion && $draftVersion != $liveVersion) { + $draft = Versioned::get_version(File::class, $fileId, $draftVersion); + } else { + $draft = null; + } + + if ($liveVersion) { + $live = Versioned::get_version(File::class, $fileId, $liveVersion); + } + } else { + $live = File::get()->byID($fileId); + } + + if (!$live) { + $this->logger->notice(sprintf('Could not find file with id %d (perhaps it has been deleted)', $fileId)); + return 0; + } + + // Check whether the file has been modified (moved) after the upload + if ($live->Version > 1) { + if ($live->ParentID != $expectedFolderId) { + // The file was updated after upload (perhaps was moved) + // We should assume that was intentional and do not process + // it, but rather make a warning here + $this->logger->notice(sprintf( + 'The file was updated after initial upload, skipping! "%s"', + $live->getField('FileFilename') + )); + } + + // check for residual files in the original folder + return $this->checkResidual($fileId, $live, $draft); + } + + if ($live->ParentID == $expectedFolderId) { + $this->logger->info(sprintf('OK: "%s"', $live->getField('FileFilename'))); + return 0; + } + + $this->logger->warning(sprintf('Found a misplaced file: "%s"', $live->getField('FileFilename'))); + + $expectedFolder = $this->getFolder($expectedFolderId); + + if ($draft) { + return $this->recoverWithDraft($live, $draft, $expectedFolder); + } else { + return $this->recoverLiveOnly($live, $expectedFolder); + } + } + + /** + * Handles gracefully a bug in UserForms that prevents + * some uploaded files from being removed on the filesystem level + * when manually moving them to another folder through CMS + * + * @see https://github.com/silverstripe/silverstripe-userforms/issues/944 + * + * @param int $fileId File.ID + * @param File $file The live version of the file + * @param File|null $draft The draft version of the file + * + * @return int Number of files recovered + */ + protected function checkResidual($fileId, File $file, File $draft = null) + { + if (!$this->filesVersioned) { + return 0; + } + + $upload = Versioned::get_version(File::class, $fileId, 1); + + if ($upload->ParentID == $file->ParentID) { + // The file is published in the original folder, so we're good + return 0; + } + + if ($draft && $upload->ParentID == $draft->ParentID) { + // The file draft is residing in the same folder where it + // has been uploaded originally. It's under the draft's control now + return 0; + } + + $deleted = 0; + $dbFile = $upload->File; + + if ($dbFile->exists()) { + // Find if another file record refer to the same physical location + $another = Versioned::get_by_stage(File::class, Versioned::LIVE, [ + '"ID" != ?' => $fileId, + '"FileFilename"' => $dbFile->Filename, + '"FileHash"' => $dbFile->Hash, + '"FileVariant"' => $dbFile->Variant + ])->exists(); + + // A lazy check for draft (no check if we already found live) + $another = $another || Versioned::get_by_stage(File::class, Versioned::DRAFT, [ + '"ID" != ?' => $fileId, + '"FileFilename"' => $dbFile->Filename, + '"FileHash"' => $dbFile->Hash, + '"FileVariant"' => $dbFile->Variant + ])->exists(); + + if (!$another) { + $this->logger->warning(sprintf('Found a residual file on the filesystem, going to delete it: "%s"', $dbFile->Filename)); + if ($dbFile->deleteFile()) { + $this->logger->warning(sprintf('DELETE: "%s"', $dbFile->Filename)); + ++$deleted; + } else { + $this->logger->warning(sprintf('FAILED TO DELETE: "%s"', $dbFile->Filename)); + } + } + } + + return $deleted; + } + + /** + * Recover a file with only Live version (with no draft) + * + * @param File $file the file instance + * @param int $expectedFolder The expected folder + * + * @return int How many files have been recovered + */ + protected function recoverLiveOnly(File $file, Folder $expectedFolder) + { + $this->logger->warning(sprintf('MOVE: "%s" to %s', $file->Filename, $expectedFolder->Filename)); + return $this->moveFileToFolder($file, $expectedFolder); + } + + /** + * Recover a live version of the file preserving the draft + * + * @param File $live Live version of the file + * @param File $draft Draft version of the file + * @param Folder $expectedFolder The expected folder + * + * @return int How many files have been recovered + */ + protected function recoverWithDraft(File $live, File $draft, Folder $expectedFolder) + { + $this->logger->warning(sprintf( + 'MOVE: "%s" to "%s", preserving draft "%s"', + $live->Filename, + $expectedFolder->Filename, + $draft->Filename + )); + + $result = $this->moveFileToFolder($live, $expectedFolder); + + // Restore the DB record of the draft deleted after publishing + $draft->writeToStage(Versioned::DRAFT); + + // This hack makes it copy the file on the filesystem level. + // The file under the Filename link of the draft has been removed + // when we published the updated live version of the file. + $draft->File->Filename = $live->File->Filename; + + // If the draft parent folder has been deleted (e.g. the draft file was alone there) + // we explicitly restore it here, otherwise it + // will be lost and saved in the root directory + $draft->Parent = $this->getFolder($draft->ParentID); + + // Save the draft and copy over the file from the Live version + // on the filesystem level + $draft->write(); + + return $result; + } + + protected function moveFileToFolder(File $file, Folder $folder) + { + $file->Parent = $folder; + $file->write(); + $file->publishSingle(); + + return 1; + } + + /** + * Split queries into smaller chunks to avoid using too much memory + * @param int $chunkSize + * @return Generator + */ + private function chunk($chunkSize = 100) + { + $greaterThanID = 0; + + do { + $count = 0; + + $chunk = $this->getQuery() + ->setLimit($chunkSize) + ->addWhere([ + '"SubmittedFileFieldTable"."UploadedFileID" > ?' => $greaterThanID + ])->execute(); + + // TODO: Versioned::prepopulate_versionnumber_cache + + foreach ($chunk as $item) { + yield $item; + $greaterThanID = $item['UploadedFileID']; + ++$count; + } + } while ($count > 0); + } + + /** + * Returns SQLQuery instance + * +select + SubmittedFileField.UploadedFileID, + EditableFileField_Versions.RecordID as FieldID, + MAX(EditableFileField_Versions.Version) as FieldVersion +from + SubmittedFileField +left join + SubmittedFormField +on + SubmittedFormField.ID = SubmittedFileField.ID +left join + SubmittedForm +on + SubmittedForm.ID = SubmittedFormField.ParentID +left join + EditableFormField_Versions +on + EditableFormField_Versions.ParentID = SubmittedForm.ParentID +and + EditableFormField_Versions.Name = SubmittedFormField.Name +and + EditableFormField_Versions.LastEdited < SubmittedForm.Created +inner join + EditableFileField_Versions +on + EditableFileField_Versions.RecordID = EditableFormField_Versions.RecordID +and + EditableFileField_Versions.Version = EditableFormField_Versions.Version +where + SubmittedFileField.UploadedFileID != 0 +group by + SubmittedFileField.UploadedFileID, + EditableFileField_Versions.RecordID +order by + SubmittedFileField.UploadedFileID +limit 100 + */ + private function getQuery() + { + $schema = DataObject::getSchema(); + $submittedFileFieldTable = $schema->tableName(SubmittedFileField::class); + $submittedFormFieldTable = $schema->tableName(SubmittedFormField::class); + + $submittedFormTable = $schema->tableName(SubmittedForm::class); + + $editableFileFieldTable = $schema->tableName(EditableFileField::class); + $editableFileFieldVersionsTable = sprintf('%s_Versions', $editableFileFieldTable); + + $editableFormFieldTable = $schema->tableName(EditableFormField::class); + $editableFormFieldVersionsTable = sprintf('%s_Versions', $editableFormFieldTable); + + return SQLSelect::create() + ->setSelect([ + '"SubmittedFileFieldTable"."UploadedFileID"', + '"EditableFileFieldVersions"."RecordID" as "FieldID"', + 'MAX("EditableFileFieldVersions"."Version") as "FieldVersion"' + ]) + ->setFrom(sprintf('%s as "SubmittedFileFieldTable"', Convert::symbol2sql($submittedFileFieldTable))) + ->setWhere([ + '"SubmittedFileFieldTable"."UploadedFileID" != 0' + ]) + ->setGroupBy([ + '"SubmittedFileFieldTable"."UploadedFileID"', + '"EditableFileFieldVersions"."RecordID"' + ]) + ->addLeftJoin( + $submittedFormFieldTable, + '"SubmittedFormFieldTable"."ID" = "SubmittedFileFieldTable"."ID"', + 'SubmittedFormFieldTable' + ) + ->addLeftJoin( + $submittedFormTable, + '"SubmittedFormTable"."ID" = "SubmittedFormFieldTable"."ParentID"', + 'SubmittedFormTable' + ) + ->addLeftJoin( + $editableFormFieldVersionsTable, + sprintf( + '%s AND %s AND %s', + '"EditableFormFieldVersions"."ParentID" = "SubmittedFormTable"."ParentID"', + '"EditableFormFieldVersions"."Name" = "SubmittedFormFieldTable"."Name"', + '"EditableFormFieldVersions"."LastEdited" < "SubmittedFormTable"."Created"' + ), + 'EditableFormFieldVersions' + ) + ->addInnerJoin( + $editableFileFieldVersionsTable, + sprintf( + '%s AND %s', + '"EditableFileFieldVersions"."RecordID" = "EditableFormFieldVersions"."RecordID"', + '"EditableFileFieldVersions"."Version" = "EditableFormFieldVersions"."Version"' + ), + 'EditableFileFieldVersions' + ) + ->addOrderBy('"SubmittedFileFieldTable"."UploadedFileID"', 'ASC') + ; + } + + /** + * Returns DataList object containing every + * uploaded file record + * + * @return DataList + */ + private function getCountQuery() + { + return SubmittedFileField::get()->filter(['UploadedFileID:NOT' => 0]); + } +} \ No newline at end of file From 6276e990c1b7ffef69bb74cc2a6a2c26a6e53e53 Mon Sep 17 00:00:00 2001 From: Mojmir Fendek Date: Tue, 5 May 2020 10:18:25 +1200 Subject: [PATCH 2/4] BUG: Better anchor fallback --- client/dist/js/userforms.js | 2 +- client/src/bundles/UserForms.js | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/dist/js/userforms.js b/client/dist/js/userforms.js index 2fc19ca..447e9be 100644 --- a/client/dist/js/userforms.js +++ b/client/dist/js/userforms.js @@ -1 +1 @@ -!function(t){function e(n){if(r[n])return r[n].exports;var i=r[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,e),i.l=!0,i.exports}var r={};e.m=t,e.c=r,e.i=function(t){return t},e.d=function(t,r,n){e.o(t,r)||Object.defineProperty(t,r,{configurable:!1,enumerable:!0,get:n})},e.n=function(t){var r=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(r,"a",r),r},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s="./client/src/bundles/bundle.js")}({"./client/src/bundles/UserForms.js":function(t,e,r){"use strict";function n(t){return t&&t.__esModule?t:{default:t}}var i=r(1),s=n(i),o=r(0),a=n(o);(0,s.default)(document).ready(function(t){function e(e){return this.$el=e instanceof t?e:t(e),this.$el.find("h4").text(a.default._t("UserForms.ERROR_CONTAINER_HEADER","Please correct the following errors and try again:")),this}function r(r){var n=this;this.$el=r instanceof t?r:t(r);var i=this.$el.closest(".userform").data("inst");return this.$elButton=t(".step-button-wrapper[data-for='"+this.$el.prop("id")+"']"),this.viewed=!1,this.valid=!1,this.id=null,this.hide(),u.DISPLAY_ERROR_MESSAGES_AT_TOP&&(this.errorContainer=new e(this.$el.find(".error-container")),i.$el.on("userform.form.error",function(e,r){n.$el.is(":visible")&&t.each(r.errorList,function(e,r){n.errorContainer.updateErrorMessage(t(r.element),r.message)})}),i.$el.on("userform.form.valid",function(t,e){n.errorContainer.removeErrorMessage(e)})),this.$elButton.on("userform.field.hide userform.field.show",function(){i.$el.trigger("userform.form.conditionalstep")}),this}function n(e){var r=this;this.$el=e instanceof t?e:t(e),this.$buttons=this.$el.find(".step-button-jump"),this.$jsAlign=this.$el.find(".js-align");var n=this.$el.closest(".userform").data("inst");return this.$buttons.each(function(e,n){t(n).on("click",function(e){e.preventDefault();var n=parseInt(t(e.target).data("step"),10);r.$el.trigger("userform.progress.changestep",n)})}),n.$el.on("userform.form.changestep",function(t,e){r.update(e)}),n.$el.on("userform.form.conditionalstep",function(){var e=r.$buttons.filter(":visible");e.each(function(e,r){t(r).text(e+1)}),r.$el.find(".progress-bar").attr("aria-valuemax",e.length),r.$el.find(".total-step-number").text(e.length)}),this.$jsAlign.each(function(e,n){var i=t(n),s=100/(r.$jsAlign.length-1)*e,o=s+"%",a=i.innerWidth()/2*-1;i.css({left:o,marginLeft:a}),e===r.$jsAlign.length-1?i.css({marginLeft:2*a}):0===e&&i.css({marginLeft:0})}),this}function i(e){var r=this;return this.$el=e instanceof t?e:t(e),this.userformInstance=this.$el.closest(".userform").data("inst"),this.$prevButton=this.$el.find(".step-button-prev"),this.$nextButton=this.$el.find(".step-button-next"),this.$prevButton.parent().attr("aria-hidden",!1).show(),this.$nextButton.parent().attr("aria-hidden",!1).show(),this.$prevButton.on("click",function(t){t.preventDefault(),r.$el.trigger("userform.action.prev")}),this.$nextButton.on("click",function(t){t.preventDefault(),r.$el.trigger("userform.action.next")}),this.userformInstance.$el.on("userform.form.changestep userform.form.conditionalstep",function(){r.update()}),this}function s(r){var n=this;return this.$el=r instanceof t?r:t(r),this.steps=[],this.errorContainer=new e(this.$el.children(".error-container")),this.$el.on("userform.action.prev",function(){n.prevStep()}),this.$el.on("userform.action.next",function(){n.nextStep()}),this.$el.find(".userform-progress").on("userform.progress.changestep",function(t,e){n.jumpToStep(e-1)}),this.$el.on("userform.form.valid",function(t,e){n.errorContainer.removeStepLink(e)}),this.$el.validate(this.validationOptions),this.$el.find(".optionset.requiredField input").each(function(e,r){t(r).rules("add",{required:!0})}),this}function o(o,d){var f=this,c=t(d);if(0!==c.length){u.ENABLE_LIVE_VALIDATION=void 0!==c.data("livevalidation"),u.DISPLAY_ERROR_MESSAGES_AT_TOP=void 0!==c.data("toperrors"),!1===u.ENABLE_LIVE_VALIDATION&&t.extend(s.prototype.validationOptions,{onfocusout:!1}),u.DISPLAY_ERROR_MESSAGES_AT_TOP&&t.extend(s.prototype.validationOptions,{invalidHandler:function(t,e){c.trigger("userform.form.error",[e])},onfocusout:!1}),c.find(".userform-progress, .step-navigation").attr("aria-hidden",!1).show(),t.extend(r.prototype,l),t.extend(e.prototype,l);var h=new s(c);c.data("inst",h),u.HIDE_FIELD_LABELS&&c.find("label.left").each(function(){var e=t(f);t('[name="'+e.attr("for")+'"]').attr("placeholder",e.text()),e.remove()}),h.$el.find(".form-step").each(function(t,e){var n=new r(e);h.addStep(n)}),h.setCurrentStep(h.steps[0]);var p=c.find(".userform-progress");p.length&&new n(p).update(0);var m=c.find(".step-navigation");m.length&&new i(m).update(),t(document).on("click","input.text[data-showcalendar]",function(){var e=t(f);e.ssDatepicker(),e.data("datepicker")&&e.datepicker("show")}),setInterval(function(){t.ajax({url:"UserDefinedFormController/ping"})},18e4),void 0!==c.areYouSure&&c.areYouSure({message:a.default._t("UserForms.LEAVE_CONFIRMATION","You have unsaved changes!")})}}var u={},l={show:function(){this.$el.attr("aria-hidden",!1).show()},hide:function(){this.$el.attr("aria-hidden",!0).hide()}};e.prototype.hasErrors=function(){return this.$el.find(".error-list").children().length>0},e.prototype.removeErrorMessage=function(t){this.$el.find("#"+t+"-top-error").remove(),this.hasErrors()||this.hide()},e.prototype.addStepLink=function(e){var r=this.$el.closest(".userform").data("inst"),n=e.$el.attr("id")+"-error-link",i=this.$el.find("#"+n),s=e.$el.attr("id"),o=e.$el.data("title");i.length||(i=t('
  • '+o+"
  • "),i.on("click",function(t){t.preventDefault(),r.jumpToStep(e.id)}),this.$el.find(".error-list").append(i))},e.prototype.removeStepLink=function(e){var r=t("#"+e).closest(".form-step").attr("id");this.$el.find("#"+r+"-error-link").remove(),this.$el.find(".error-list").is(":empty")&&this.hide()},e.prototype.updateErrorMessage=function(e,r){var n=this,i=e.attr("id"),s="#"+i,o=i+"-top-error",a=t("#"+o),u=e.attr("aria-describedby");if(!r)return void a.addClass("fixed");a.removeClass("fixed"),this.show(),1===a.length?a.show().find("a").html(r):(e.closest(".field[id]").each(function(){s="#"+t(n).attr("id")}),a=t("
  • "),a.attr("id",o).find("a").attr("href",location.pathname+location.search+s).html(r),this.$el.find("ul").append(a),u?u.match(new RegExp("\\b"+o+"\\b"))||(u+=" "+o):u=o,e.attr("aria-describedby",u))},r.prototype.conditionallyHidden=function(){return!this.$elButton.find("button").is(":visible")},n.prototype.update=function(e){var r=t(this.$el.parent(".userform").find(".form-step")[e]),n=0,i=e/(this.$buttons.length-1)*100;this.$buttons.each(function(r,i){return!(r>e||(t(i).is(":visible")&&(n+=1),0))}),this.$el.find(".current-step-number").each(function(e,r){t(r).text(n)}),this.$el.find("[aria-valuenow]").each(function(e,r){t(r).attr("aria-valuenow",n)}),this.$buttons.each(function(e,r){var i=t(r),s=i.parent();if(parseInt(i.data("step"),10)===n&&i.is(":visible"))return s.addClass("current viewed"),void i.removeAttr("disabled");s.removeClass("current")}),this.$el.siblings(".progress-title").text(r.data("title")),i=i?i+"%":"",this.$el.find(".progress-bar").width(i)},i.prototype.update=function(){var t=this.userformInstance.steps.length,e=this.userformInstance.currentStep?this.userformInstance.currentStep.id:0,r=null,n=null;for(this.$el.find(".step-button-prev")[0===e?"hide":"show"](),r=t-1;r>=0;r--)if(n=this.userformInstance.steps[r],!n.conditionallyHidden()){this.$el.find(".step-button-next")[e>=r?"hide":"show"](),this.$el.find(".btn-toolbar")[e>=r?"show":"hide"]();break}},s.prototype.validationOptions={ignore:":hidden,ul",errorClass:"error",errorElement:"span",errorPlacement:function(t,e){t.addClass("message"),e.is(":radio")||e.parents(".checkboxset").length>0?t.appendTo(e.closest(".middleColumn")):e.parents(".checkbox").length>0?t.appendTo(e.closest(".field")):t.insertAfter(e)},invalidHandler:function(t,e){setTimeout(function(){e.currentElements.filter(".error").first().focus()},0)},submitHandler:function(e){var r=!0,n=t(e).closest(".userform").data("inst");n.currentStep&&(n.currentStep.valid=t(e).valid()),t.each(n.steps,function(t,e){e.valid||e.conditionallyHidden()||(r=!1,n.errorContainer.addStepLink(e))}),r?(t(e).removeClass("dirty"),e.submit(),n.$el.trigger("userform.form.submit")):n.errorContainer.show()},success:function(e){var r=t(e).closest(".userform").data("inst"),n=t(e).attr("id"),i=n.substr(0,n.indexOf("-error")).replace(/[\\[\\]]/,"");e.remove(),r.$el.trigger("userform.form.valid",[i])}},s.prototype.addStep=function(t){t instanceof r&&(t.id=this.steps.length,this.steps.push(t))},s.prototype.setCurrentStep=function(t){t instanceof r&&(this.currentStep=t,this.currentStep.show(),this.currentStep.viewed=!0,this.currentStep.$el.addClass("viewed"))},s.prototype.jumpToStep=function(t,e){var r=this.steps[t],n=!1,i=void 0===e||e;if(void 0!==r){if(r.conditionallyHidden())return void(i?this.jumpToStep(t+1):this.jumpToStep(t-1));n=this.$el.valid(),this.currentStep.valid=n,!1===n&&!1===r.viewed||(this.currentStep.hide(),this.setCurrentStep(r),this.$el.trigger("userform.form.changestep",[r.id]))}},s.prototype.nextStep=function(){this.jumpToStep(this.steps.indexOf(this.currentStep)+1,!0)},s.prototype.prevStep=function(){this.jumpToStep(this.steps.indexOf(this.currentStep)-1,!1)},t(".userform").each(o)})},"./client/src/bundles/bundle.js":function(t,e,r){"use strict";r("./client/src/bundles/UserForms.js")},0:function(t,e){t.exports=i18n},1:function(t,e){t.exports=jQuery}}); \ No newline at end of file +!function(t){function e(n){if(r[n])return r[n].exports;var i=r[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,e),i.l=!0,i.exports}var r={};e.m=t,e.c=r,e.i=function(t){return t},e.d=function(t,r,n){e.o(t,r)||Object.defineProperty(t,r,{configurable:!1,enumerable:!0,get:n})},e.n=function(t){var r=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(r,"a",r),r},e.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},e.p="",e(e.s="./client/src/bundles/bundle.js")}({"./client/src/bundles/UserForms.js":function(t,e,r){"use strict";function n(t){return t&&t.__esModule?t:{default:t}}var i=r(1),s=n(i),o=r(0),a=n(o);(0,s.default)(document).ready(function(t){function e(e){return this.$el=e instanceof t?e:t(e),this.$el.find("h4").text(a.default._t("UserForms.ERROR_CONTAINER_HEADER","Please correct the following errors and try again:")),this}function r(r){var n=this;this.$el=r instanceof t?r:t(r);var i=this.$el.closest(".userform").data("inst");return this.$elButton=t(".step-button-wrapper[data-for='"+this.$el.prop("id")+"']"),this.viewed=!1,this.valid=!1,this.id=null,this.hide(),u.DISPLAY_ERROR_MESSAGES_AT_TOP&&(this.errorContainer=new e(this.$el.find(".error-container")),i.$el.on("userform.form.error",function(e,r){n.$el.is(":visible")&&t.each(r.errorList,function(e,r){n.errorContainer.updateErrorMessage(t(r.element),r.message)})}),i.$el.on("userform.form.valid",function(t,e){n.errorContainer.removeErrorMessage(e)})),this.$elButton.on("userform.field.hide userform.field.show",function(){i.$el.trigger("userform.form.conditionalstep")}),this}function n(e){var r=this;this.$el=e instanceof t?e:t(e),this.$buttons=this.$el.find(".step-button-jump"),this.$jsAlign=this.$el.find(".js-align");var n=this.$el.closest(".userform").data("inst");return this.$buttons.each(function(e,n){t(n).on("click",function(e){e.preventDefault();var n=parseInt(t(e.target).data("step"),10);r.$el.trigger("userform.progress.changestep",n)})}),n.$el.on("userform.form.changestep",function(t,e){r.update(e)}),n.$el.on("userform.form.conditionalstep",function(){var e=r.$buttons.filter(":visible");e.each(function(e,r){t(r).text(e+1)}),r.$el.find(".progress-bar").attr("aria-valuemax",e.length),r.$el.find(".total-step-number").text(e.length)}),this.$jsAlign.each(function(e,n){var i=t(n),s=100/(r.$jsAlign.length-1)*e,o=s+"%",a=i.innerWidth()/2*-1;i.css({left:o,marginLeft:a}),e===r.$jsAlign.length-1?i.css({marginLeft:2*a}):0===e&&i.css({marginLeft:0})}),this}function i(e){var r=this;return this.$el=e instanceof t?e:t(e),this.userformInstance=this.$el.closest(".userform").data("inst"),this.$prevButton=this.$el.find(".step-button-prev"),this.$nextButton=this.$el.find(".step-button-next"),this.$prevButton.parent().attr("aria-hidden",!1).show(),this.$nextButton.parent().attr("aria-hidden",!1).show(),this.$prevButton.on("click",function(t){t.preventDefault(),r.$el.trigger("userform.action.prev")}),this.$nextButton.on("click",function(t){t.preventDefault(),r.$el.trigger("userform.action.next")}),this.userformInstance.$el.on("userform.form.changestep userform.form.conditionalstep",function(){r.update()}),this}function s(r){var n=this;return this.$el=r instanceof t?r:t(r),this.steps=[],this.errorContainer=new e(this.$el.children(".error-container")),this.$el.on("userform.action.prev",function(){n.prevStep()}),this.$el.on("userform.action.next",function(){n.nextStep()}),this.$el.find(".userform-progress").on("userform.progress.changestep",function(t,e){n.jumpToStep(e-1)}),this.$el.on("userform.form.valid",function(t,e){n.errorContainer.removeStepLink(e)}),this.$el.validate(this.validationOptions),this.$el.find(".optionset.requiredField input").each(function(e,r){t(r).rules("add",{required:!0})}),this}function o(o,d){var f=this,c=t(d);if(0!==c.length){u.ENABLE_LIVE_VALIDATION=void 0!==c.data("livevalidation"),u.DISPLAY_ERROR_MESSAGES_AT_TOP=void 0!==c.data("toperrors"),!1===u.ENABLE_LIVE_VALIDATION&&t.extend(s.prototype.validationOptions,{onfocusout:!1}),u.DISPLAY_ERROR_MESSAGES_AT_TOP&&t.extend(s.prototype.validationOptions,{invalidHandler:function(t,e){c.trigger("userform.form.error",[e])},onfocusout:!1}),c.find(".userform-progress, .step-navigation").attr("aria-hidden",!1).show(),t.extend(r.prototype,l),t.extend(e.prototype,l);var h=new s(c);c.data("inst",h),u.HIDE_FIELD_LABELS&&c.find("label.left").each(function(){var e=t(f);t('[name="'+e.attr("for")+'"]').attr("placeholder",e.text()),e.remove()}),h.$el.find(".form-step").each(function(t,e){var n=new r(e);h.addStep(n)}),h.setCurrentStep(h.steps[0]);var p=c.find(".userform-progress");p.length&&new n(p).update(0);var m=c.find(".step-navigation");m.length&&new i(m).update(),t(document).on("click","input.text[data-showcalendar]",function(){var e=t(f);e.ssDatepicker(),e.data("datepicker")&&e.datepicker("show")}),setInterval(function(){t.ajax({url:"UserDefinedFormController/ping"})},18e4),void 0!==c.areYouSure&&c.areYouSure({message:a.default._t("UserForms.LEAVE_CONFIRMATION","You have unsaved changes!")})}}var u={},l={show:function(){this.$el.attr("aria-hidden",!1).show()},hide:function(){this.$el.attr("aria-hidden",!0).hide()}};e.prototype.hasErrors=function(){return this.$el.find(".error-list").children().length>0},e.prototype.removeErrorMessage=function(t){this.$el.find("#"+t+"-top-error").remove(),this.hasErrors()||this.hide()},e.prototype.addStepLink=function(e){var r=this.$el.closest(".userform").data("inst"),n=e.$el.attr("id")+"-error-link",i=this.$el.find("#"+n),s=e.$el.attr("id"),o=e.$el.data("title");i.length||(i=t('
  • '+o+"
  • "),i.on("click",function(t){t.preventDefault(),r.jumpToStep(e.id)}),this.$el.find(".error-list").append(i))},e.prototype.removeStepLink=function(e){var r=t("#"+e).closest(".form-step").attr("id");this.$el.find("#"+r+"-error-link").remove(),this.$el.find(".error-list").is(":empty")&&this.hide()},e.prototype.updateErrorMessage=function(e,r){var n=this,i=e.attr("id"),s="#"+i,o=i+"-top-error",a=t("#"+o),u=e.attr("aria-describedby");if(!r)return void a.addClass("fixed");a.removeClass("fixed"),this.show(),1===a.length?a.show().find("a").html(r):(e.closest(".field[id]").each(function(){var e=t(n).attr("id");e&&(s="#"+e)}),a=t("
  • "),a.attr("id",o).find("a").attr("href",location.pathname+location.search+s).html(r),this.$el.find("ul").append(a),u?u.match(new RegExp("\\b"+o+"\\b"))||(u+=" "+o):u=o,e.attr("aria-describedby",u))},r.prototype.conditionallyHidden=function(){return!this.$elButton.find("button").is(":visible")},n.prototype.update=function(e){var r=t(this.$el.parent(".userform").find(".form-step")[e]),n=0,i=e/(this.$buttons.length-1)*100;this.$buttons.each(function(r,i){return!(r>e||(t(i).is(":visible")&&(n+=1),0))}),this.$el.find(".current-step-number").each(function(e,r){t(r).text(n)}),this.$el.find("[aria-valuenow]").each(function(e,r){t(r).attr("aria-valuenow",n)}),this.$buttons.each(function(e,r){var i=t(r),s=i.parent();if(parseInt(i.data("step"),10)===n&&i.is(":visible"))return s.addClass("current viewed"),void i.removeAttr("disabled");s.removeClass("current")}),this.$el.siblings(".progress-title").text(r.data("title")),i=i?i+"%":"",this.$el.find(".progress-bar").width(i)},i.prototype.update=function(){var t=this.userformInstance.steps.length,e=this.userformInstance.currentStep?this.userformInstance.currentStep.id:0,r=null,n=null;for(this.$el.find(".step-button-prev")[0===e?"hide":"show"](),r=t-1;r>=0;r--)if(n=this.userformInstance.steps[r],!n.conditionallyHidden()){this.$el.find(".step-button-next")[e>=r?"hide":"show"](),this.$el.find(".btn-toolbar")[e>=r?"show":"hide"]();break}},s.prototype.validationOptions={ignore:":hidden,ul",errorClass:"error",errorElement:"span",errorPlacement:function(t,e){t.addClass("message"),e.is(":radio")||e.parents(".checkboxset").length>0?t.appendTo(e.closest(".middleColumn")):e.parents(".checkbox").length>0?t.appendTo(e.closest(".field")):t.insertAfter(e)},invalidHandler:function(t,e){setTimeout(function(){e.currentElements.filter(".error").first().focus()},0)},submitHandler:function(e){var r=!0,n=t(e).closest(".userform").data("inst");n.currentStep&&(n.currentStep.valid=t(e).valid()),t.each(n.steps,function(t,e){e.valid||e.conditionallyHidden()||(r=!1,n.errorContainer.addStepLink(e))}),r?(t(e).removeClass("dirty"),e.submit(),n.$el.trigger("userform.form.submit")):n.errorContainer.show()},success:function(e){var r=t(e).closest(".userform").data("inst"),n=t(e).attr("id"),i=n.substr(0,n.indexOf("-error")).replace(/[\\[\\]]/,"");e.remove(),r.$el.trigger("userform.form.valid",[i])}},s.prototype.addStep=function(t){t instanceof r&&(t.id=this.steps.length,this.steps.push(t))},s.prototype.setCurrentStep=function(t){t instanceof r&&(this.currentStep=t,this.currentStep.show(),this.currentStep.viewed=!0,this.currentStep.$el.addClass("viewed"))},s.prototype.jumpToStep=function(t,e){var r=this.steps[t],n=!1,i=void 0===e||e;if(void 0!==r){if(r.conditionallyHidden())return void(i?this.jumpToStep(t+1):this.jumpToStep(t-1));n=this.$el.valid(),this.currentStep.valid=n,!1===n&&!1===r.viewed||(this.currentStep.hide(),this.setCurrentStep(r),this.$el.trigger("userform.form.changestep",[r.id]))}},s.prototype.nextStep=function(){this.jumpToStep(this.steps.indexOf(this.currentStep)+1,!0)},s.prototype.prevStep=function(){this.jumpToStep(this.steps.indexOf(this.currentStep)-1,!1)},t(".userform").each(o)})},"./client/src/bundles/bundle.js":function(t,e,r){"use strict";r("./client/src/bundles/UserForms.js")},0:function(t,e){t.exports=i18n},1:function(t,e){t.exports=jQuery}}); \ No newline at end of file diff --git a/client/src/bundles/UserForms.js b/client/src/bundles/UserForms.js index a740cdf..a611e43 100644 --- a/client/src/bundles/UserForms.js +++ b/client/src/bundles/UserForms.js @@ -139,7 +139,13 @@ jQuery(document).ready(($) => { } else { // Generate better link to field $input.closest('.field[id]').each(() => { - anchor = `#${$(this).attr('id')}`; + const anchorID = $(this).attr('id'); + + if (!anchorID) { + return; + } + + anchor = `#${anchorID}`; }); // Add a new error message From ede2d933631300d700a3207a1b61bbd69e80b1f2 Mon Sep 17 00:00:00 2001 From: Dan Hensby Date: Tue, 5 May 2020 16:30:57 +0100 Subject: [PATCH 3/4] Linting fixes --- code/Task/RecoverUploadLocationsHelper.php | 33 +++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/code/Task/RecoverUploadLocationsHelper.php b/code/Task/RecoverUploadLocationsHelper.php index 2b0e8a2..c0f261d 100644 --- a/code/Task/RecoverUploadLocationsHelper.php +++ b/code/Task/RecoverUploadLocationsHelper.php @@ -62,7 +62,7 @@ class RecoverUploadLocationsHelper /** * Cache of the EditableFileField versions - * + * * @var EditableFileField */ private $fieldFolderCache = array(); @@ -115,7 +115,7 @@ class RecoverUploadLocationsHelper /** * Process all the files and return the number - * + * * @return int Number of files processed */ protected function process() @@ -134,7 +134,7 @@ class RecoverUploadLocationsHelper $errorsCount = 0; // Loop over the files to process - foreach($this->chunk() as $uploadRecord) { + foreach ($this->chunk() as $uploadRecord) { ++$processedCount; $fileId = $uploadRecord['UploadedFileID']; @@ -171,10 +171,10 @@ class RecoverUploadLocationsHelper /** * Fetches the EditableFileField version from cache and returns its FolderID - * + * * @param int $fieldId EditableFileField.ID * @param int EditableFileField Version - * + * * @return int */ protected function getExpectedUploadFolderId($fieldId, $fieldVersion) @@ -196,11 +196,11 @@ class RecoverUploadLocationsHelper /** * Fetches a Folder by its ID, gracefully handling * deleted folders - * + * * @param int $id Folder.ID - * + * * @return Folder - * + * * @throws RuntimeException when folder could not be found */ protected function getFolder($id) @@ -231,10 +231,10 @@ class RecoverUploadLocationsHelper /** * Recover an uploaded file location - * + * * @param int $fileId File.ID * @param int $expectedFolderId ID of the folder where the file should have end up - * + * * @return int Number of files recovered */ protected function recover($fileId, $expectedFolderId) @@ -247,7 +247,8 @@ class RecoverUploadLocationsHelper if ($this->filesVersioned) { $draftVersion = Versioned::get_versionnumber_by_stage(File::class, Versioned::DRAFT, $fileId); - $liveVersion = Versioned::get_versionnumber_by_stage(File::class, Versioned::LIVE, $fileId);; + $liveVersion = Versioned::get_versionnumber_by_stage(File::class, Versioned::LIVE, $fileId); + ; if ($draftVersion && $draftVersion != $liveVersion) { $draft = Versioned::get_version(File::class, $fileId, $draftVersion); @@ -305,11 +306,11 @@ class RecoverUploadLocationsHelper * when manually moving them to another folder through CMS * * @see https://github.com/silverstripe/silverstripe-userforms/issues/944 - * + * * @param int $fileId File.ID * @param File $file The live version of the file * @param File|null $draft The draft version of the file - * + * * @return int Number of files recovered */ protected function checkResidual($fileId, File $file, File $draft = null) @@ -370,7 +371,7 @@ class RecoverUploadLocationsHelper * * @param File $file the file instance * @param int $expectedFolder The expected folder - * + * * @return int How many files have been recovered */ protected function recoverLiveOnly(File $file, Folder $expectedFolder) @@ -560,11 +561,11 @@ limit 100 /** * Returns DataList object containing every * uploaded file record - * + * * @return DataList */ private function getCountQuery() { return SubmittedFileField::get()->filter(['UploadedFileID:NOT' => 0]); } -} \ No newline at end of file +} From e86cc8d8721b1372aca4f4f8f0b74398fae909c6 Mon Sep 17 00:00:00 2001 From: Dan Hensby Date: Tue, 5 May 2020 16:32:54 +0100 Subject: [PATCH 4/4] Add lint and lint-clean scripts to composer --- .travis.yml | 2 +- composer.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 930450d..cc3ff30 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,7 +38,7 @@ before_script: script: - if [[ $PHPUNIT_TEST ]]; then vendor/bin/phpunit; fi - if [[ $PHPUNIT_COVERAGE_TEST ]]; then phpdbg -qrr vendor/bin/phpunit --coverage-clover=coverage.xml -vvv; fi - - if [[ $PHPCS_TEST ]]; then vendor/bin/phpcs code/ tests/; fi + - if [[ $PHPCS_TEST ]]; then composer run-script lint; fi - if [[ $NPM_TEST ]]; then git diff-files --quiet -w --relative=client; fi - if [[ $NPM_TEST ]]; then git diff -w --no-color --relative=client; fi - if [[ $NPM_TEST ]]; then yarn run lint; fi diff --git a/composer.json b/composer.json index b4344a9..8cb0299 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,10 @@ "colymba/gridfield-bulk-editing-tools": "Allows for bulk management of form submissions", "silverstripe/gridfieldqueuedexport": "Export large submission as CSV through queued jobs in the background" }, + "scripts": { + "lint": "phpcs code/ tests/", + "lint-clean": "phpcbf code/ tests/" + }, "extra": { "expose": [ "client/dist",