diff --git a/package.json b/package.json index e56284f02..6c6d5cb46 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,10 @@ "@tiptap/extension-underline": "^2.0.0-beta.25", "@tiptap/starter-kit": "^2.0.0-beta.191", "@tiptap/vue-3": "^2.0.0-beta.96", + "@uppy/core": "^4.2.0", + "@uppy/dashboard": "^4.1.0", + "@uppy/form": "^4.0.0", + "@uppy/xhr-upload": "^4.1.0", "@yaireo/tagify": "^4.12.0", "click-outside-vue3": "^4.0.1", "expression-language": "^1.1.4", diff --git a/src/controllers/FileUploadController.php b/src/controllers/FileUploadController.php new file mode 100644 index 000000000..5ec453fe5 --- /dev/null +++ b/src/controllers/FileUploadController.php @@ -0,0 +1,369 @@ + self::ALLOW_ANONYMOUS_LIVE, + 'remove-file' => self::ALLOW_ANONYMOUS_LIVE, + 'process-file' => self::ALLOW_ANONYMOUS_LIVE, + ]; + + // Private Properties + // ========================================================================= + + private string $_namespace = 'fields'; + + // Public Methods + // ========================================================================= + + /** + * @inheritdoc + */ + public function beforeAction($action): bool + { + return parent::beforeAction($action); + } + + /** + * @return Response + * @throws HttpException + * @throws RangeNotSatisfiableHttpException + */ + public function actionLoadFile(): Response { + $id = $this->request->getParam('id'); + $asset = Asset::find() + ->id($id) + ->one(); + $content = $asset->getContents(); + return $this->response->sendContentAsFile($content, "$asset->title.$asset->extension", [ + 'inline' => true, + 'mimeType' => $asset->mimeType + ]); + } + + /** + * @return Response + * @throws BadRequestHttpException + */ + public function actionRemoveFile(): Response { + $this->requirePostRequest(); + + $handle = $this->_getTypedParam('handle', 'string'); + + /* @var Form $form */ + $form = $this->_getForm($handle); + + if (!$form) { + throw new BadRequestHttpException("No form exists with the handle \"$handle\""); + } + + $removeFile = $this->_getTypedParam('removeFile', 'id'); + + if (!$removeFile) { + throw new BadRequestHttpException("No asset id passed in request."); + } + + $asset = Craft::$app->getAssets()->getAssetById($removeFile); + + if ($asset) { + $this->_deleteAsset($removeFile); + } else { + throw new BadRequestHttpException("No asset exists with the ID: \"$removeFile\""); + } + + + return $this->asRaw(true); + } + + /** + * @return Response + * @throws BadRequestHttpException + * @throws InvalidConfigException + * @throws Throwable + * @throws ElementNotFoundException + * @throws InvalidFieldException + * @throws VolumeException + * @throws Exception + */ + public function actionProcessFile(): Response { + $this->requirePostRequest(); + $handle = $this->_getTypedParam('handle', 'string'); + $submissionId = $this->_getTypedParam('submissionId', 'id'); + + + /* @var Form $form */ + $form = $this->_getForm($handle); + + if (!$form) { + throw new BadRequestHttpException("No form exists with the handle \"$handle\""); + } + + $submission = $this->_populateSubmission($form); + $initiator = $this->_getTypedParam('initiator', 'string'); + $parent = $this->_getTypedParam('parent', 'string'); + $isNewRow = $this->_getTypedParam('isNewRow', 'boolean'); + $rowIndex = $this->_getTypedParam('rowIndex', 'int'); + $field = $form->getFieldByHandle($parent ?: $initiator); + if ($parent) { + $field = $field->getFieldByHandle($initiator); + } + $volume = Craft::$app->getVolumes()->getVolumeByUid(explode(':', $field->uploadLocationSource)[1]); + $file = UploadedFile::getInstanceByName("file"); + + $filename = Assets::prepareAssetName($file->name); + $folder = Craft::$app->getAssets()->getRootFolderByVolumeId($volume->id); + $subpath = $field->uploadLocationSubpath; + if ($field->uploadLocationSubpath) { + $folder = Craft::$app->getAssets()->ensureFolderByFullPathAndVolume($subpath, $volume); + } + $asset = new Asset(); + $asset->tempFilePath = $file->tempName; + $asset->setFilename($filename); + $asset->newFolderId = $folder->id; + $asset->setVolumeId($volume->id); + $asset->uploaderId = Craft::$app->getUser()->getId(); + $asset->avoidFilenameConflicts = true; + + $asset->setScenario(Asset::SCENARIO_CREATE); + $result = Craft::$app->getElements()->saveElement($asset); + + if ($submissionId) { + $assetIds = $this->_getAssetIdsFromSubmission($submission, $parent, $initiator, $isNewRow, $rowIndex); + $assetIds[] = $asset->id; + + // Save the row or the submission + + if ($parent) { + $row = $this->_getRowFromSubmission($submission, $parent, $isNewRow, $rowIndex); + $success = $this->_setAssetIdsForRow($assetIds, $row, $initiator); + } else { + $success = $this->_setAssetIdsForSubmission($assetIds, $submission, $initiator); + } + + if ($success) { + return $this->asJson(["id" => $asset->id, "url" => $asset->getUrl(), "submissionId" => $submission->id]); + } else { + throw new BadRequestHttpException("Unable to save Formie submission."); + } + } + + if ($result) { + return $this->asJson(["id" => $asset->id, "url" => $asset->getUrl()]); + } + + throw new BadRequestHttpException("Unable to process upload asset request."); + } + + // Private Methods + // ========================================================================= + + private function _populateSubmission($form, $isIncomplete = true): Submission + { + $request = $this->request; + + // Ensure we validate some params here to prevent potential malicious-ness + $editingSubmission = $this->_getTypedParam('editingSubmission', 'boolean'); + $submissionId = $this->_getTypedParam('submissionId', 'id'); + $siteId = $this->_getTypedParam('siteId', 'id'); + $userParam = $request->getBodyParam('user'); + + if ($submissionId) { + // Allow fetching spammed submissions for multistep forms, where it has been flagged as spam + // already, but we want to complete the form submission. + $submission = Submission::find() + ->id($submissionId) + ->isIncomplete($isIncomplete) + ->isSpam(null) + ->one(); + + if (!$submission) { + throw new BadRequestHttpException("No submission exists with the ID \"$submissionId\""); + } + } else { + $submission = new Submission(); + } + + $submission->setForm($form); + + $siteId = $siteId ?: null; + $submission->siteId = $siteId ?? $submission->siteId ?? Craft::$app->getSites()->getCurrentSite()->id; + + $submission->setFieldValuesFromRequest($this->_namespace); + $submission->setFieldParamNamespace($this->_namespace); + + // Only ever set for a brand-new submission + if (!$submission->id && $form->settings->collectIp) { + $submission->ipAddress = $request->userIP; + } + + if ($form->settings->collectUser) { + if ($user = Craft::$app->getUser()->getIdentity()) { + $submission->setUser($user); + } + + // Allow a `user` override (when editing a submission through the CP) + if ($request->getIsCpRequest() && $userParam) { + $submission->userId = $userParam[0] ?? null; + } + } + + $this->_setTitle($submission, $form); + + // If we're editing a submission, ensure we set our flag + if ($editingSubmission) { + $form->setSubmission($submission); + } + + return $submission; + } + + private function _getTypedParam(string $name, string $type, mixed $default = null, bool $bodyParam = true): mixed + { + $request = $this->request; + + if ($bodyParam) { + $value = $request->getBodyParam($name); + } else { + $value = $request->getParam($name); + } + + // Special case for `submitAction`, where we don't want just anything passed in to change behaviour + if ($name === 'submitAction') { + if (!in_array($value, ['submit', 'back', 'save'])) { + return $default; + } + } + + if ($value !== null) { + // Go case-by-case, so it's easier to handle, and more predictable + if ($type === 'string' && is_string($value)) { + return $value; + } + + if ($type === 'boolean' && is_string($value)) { + return StringHelper::toBoolean($value); + } + + if ($type === 'int' && (is_numeric($value) || $value === '')) { + return (int)$value; + } + + if ($type === 'id' && is_numeric($value) && (int)$value > 0) { + return (int)$value; + } + + throw new BadRequestHttpException('Request has invalid param ' . $name); + } + + return $default; + } + + private function _setTitle($submission, $form): void + { + $submission->title = Variables::getParsedValue($form->settings->submissionTitleFormat, $submission, $form); + + // Set the default title for the submission, so it can save correctly + if (!$submission->title) { + $now = new DateTime('now', new DateTimeZone(Craft::$app->getTimeZone())); + $submission->title = $now->format('D, d M Y H:i:s'); + } + } + + private function _getForm(string $handle): ?Form + { + $form = Form::find()->handle($handle)->one(); + + if ($form) { + if ($sessionKey = $this->_getTypedParam('sessionKey', 'string')) { + $form->setSessionKey($sessionKey); + } + } + + return $form; + } + + private function _deleteAsset($assetId) { + $asset = Craft::$app->getAssets()->getAssetById($assetId); + + if (!$asset) { + throw new BadRequestHttpException("Invalid asset ID: $assetId"); + } + + // Check if it's possible to delete objects in the target volume. + $this->requireVolumePermissionByAsset('deleteAssets', $asset); + $this->requirePeerVolumePermissionByAsset('deletePeerAssets', $asset); + + $success = Craft::$app->getElements()->deleteElement($asset, true); + + if (!$success) { + throw new BadRequestHttpException("Unable to delete asset on disk: $assetId", "Upload"); + } + } + + private function _getAssetIdsFromSubmission(Submission $submission, $parent, $initiator, $isNewRow, $rowIndex) { + if (!$parent) { + return $submission->getFieldValue($initiator)->ids(); + } + $row = $this->_getRowFromSubmission($submission, $parent, $isNewRow, $rowIndex); + return $row->getFieldValue($initiator)->ids(); + } + + private function _setAssetIdsForSubmission(array $assetIds, Submission $submission, $initiator) { + $submission->setFieldValue($initiator, $assetIds); + return Craft::$app->getElements()->saveElement($submission); + } + + private function _getRowFromSubmission(Submission $submission, $parent, $isNewRow, $rowIndex) { + $result = null; + $rows = $submission->getFieldValue($parent)->all(); + $rowCounter = 1; + foreach($rows as $row) { + if (!$isNewRow && $row->id == $rowIndex) { + $result = $row; + } else { + if ($rowCounter == $rowIndex) { + $result = $row; + } + $rowCounter++; + } + } + return $result; + } + + private function _setAssetIdsForRow($assetIds, $row, $initiator) { + $row->setFieldValue($initiator, $assetIds); + return Craft::$app->getElements()->saveElement($row); + } +} \ No newline at end of file diff --git a/src/templates/_special/form-template/fields/file-upload.html b/src/templates/_special/form-template/fields/file-upload.html index f9d29115d..471448c0e 100644 --- a/src/templates/_special/form-template/fields/file-upload.html +++ b/src/templates/_special/form-template/fields/file-upload.html @@ -1,31 +1,16 @@ -{# Because of browser limitations, we can't populate a `` field if we are on a #} -{# multi-page, page reload form with already uploaded assets. As such, we'll get an validation #} -{# error when going back to a previous page and submitting again, as the field will be empty #} -{# despite files being uploaded. Here, force the field to be non-required if a value exists. #} +{# If the submission has files submitted already, and we are on a multi-page, page reload form with #} +{# already uploaded assets, we need to delegate this task to a third-party library to handle #} +{# populating the `` field. The data object below provides this, but force #} +{# the field to be non-required if a value exists anyway. #} {% set required = value and value.all() ? false : field.required %} {{ fieldtag('fieldInput', { required: required ? true : false, + data: { + 'field-handle': field.handle, + 'files': value and value.all() ? value.ids(), + 'allowed-kinds': field.restrictFiles ? field.allowedKinds : [], + 'readable-accept': field.accept, + 'parent': field.isNested ? field.parentField.handle : false, + } }) }} - -{% if value %} - {% set elements = value.all() %} - - {% if elements %} - {% fieldtag 'fieldSummary' %} - {% if elements | length == 1 %} -
{{ '{num} file uploaded.' | t('formie', { num: elements | length }) }}
- {% else %} -{{ '{num} files uploaded.' | t('formie', { num: elements | length }) }}
- {% endif %} - - {% fieldtag 'fieldSummaryContainer' %} - {% for element in elements %} - {% fieldtag 'fieldSummaryItem' %} - {{ element.filename }} - {% endfieldtag %} - {% endfor %} - {% endfieldtag %} - {% endfieldtag %} - {% endif %} -{% endif %} \ No newline at end of file diff --git a/src/web/assets/frontend/dist/js/fields/file-upload.js b/src/web/assets/frontend/dist/js/fields/file-upload.js index ca3295edc..9a7d0fdf1 100644 --- a/src/web/assets/frontend/dist/js/fields/file-upload.js +++ b/src/web/assets/frontend/dist/js/fields/file-upload.js @@ -1 +1,2 @@ -!function(){var t={3200:function(t,r,n){var e=n(7230),o=n(933),i=n(321),u=e.TypeError;t.exports=function(t){if(o(t))return t;throw u(i(t)+" is not a function")}},4831:function(t,r,n){var e=n(7230),o=n(3538),i=n(321),u=e.TypeError;t.exports=function(t){if(o(t))return t;throw u(i(t)+" is not a constructor")}},8563:function(t,r,n){var e=n(7230),o=n(933),i=e.String,u=e.TypeError;t.exports=function(t){if("object"==typeof t||o(t))return t;throw u("Can't set "+i(t)+" as a prototype")}},186:function(t,r,n){var e=n(7952),o=n(6997),i=n(7108),u=e("unscopables"),c=Array.prototype;null==c[u]&&i.f(c,u,{configurable:!0,value:o(null)}),t.exports=function(t){c[u][t]=!0}},3264:function(t,r,n){"use strict";var e=n(2370).charAt;t.exports=function(t,r,n){return r+(n?e(t,r).length:1)}},5209:function(t,r,n){var e=n(7230),o=n(2346),i=e.TypeError;t.exports=function(t,r){if(o(r,t))return t;throw i("Incorrect invocation")}},3536:function(t,r,n){var e=n(7230),o=n(6913),i=e.String,u=e.TypeError;t.exports=function(t){if(o(t))return t;throw u(i(t)+" is not an object")}},866:function(t,r,n){"use strict";var e=n(1569).forEach,o=n(2245)("forEach");t.exports=o?[].forEach:function(t){return e(this,t,arguments.length>1?arguments[1]:void 0)}},8897:function(t,r,n){"use strict";var e=n(7230),o=n(1248),i=n(4225),u=n(987),c=n(6996),a=n(1855),f=n(3538),s=n(1646),l=n(3859),p=n(9853),v=n(6418),d=e.Array;t.exports=function(t){var r=u(t),n=f(this),e=arguments.length,h=e>1?arguments[1]:void 0,y=void 0!==h;y&&(h=o(h,e>2?arguments[2]:void 0));var g,m,b,x,S,w,O=v(r),j=0;if(!O||this==d&&a(O))for(g=s(r),m=n?new this(g):d(g);g>j;j++)w=y?h(r[j],j):r[j],l(m,j,w);else for(S=(x=p(r,O)).next,m=n?new this:[];!(b=i(S,x)).done;j++)w=y?c(x,h,[b.value,j],!0):b.value,l(m,j,w);return m.length=j,m}},7945:function(t,r,n){var e=n(9164),o=n(2966),i=n(1646),u=function(t){return function(r,n,u){var c,a=e(r),f=i(a),s=o(u,f);if(t&&n!=n){for(;f>s;)if((c=a[s++])!=c)return!0}else for(;f>s;s++)if((t||s in a)&&a[s]===n)return t||s||0;return!t&&-1}};t.exports={includes:u(!0),indexOf:u(!1)}},1569:function(t,r,n){var e=n(1248),o=n(1916),i=n(6801),u=n(987),c=n(1646),a=n(1204),f=o([].push),s=function(t){var r=1==t,n=2==t,o=3==t,s=4==t,l=6==t,p=7==t,v=5==t||l;return function(d,h,y,g){for(var m,b,x=u(d),S=i(x),w=e(h,y),O=c(S),j=0,E=g||a,T=r?E(d,O):n||p?E(d,0):void 0;O>j;j++)if((v||j in S)&&(b=w(m=S[j],j,x),t))if(r)T[j]=b;else if(b)switch(t){case 3:return!0;case 5:return m;case 6:return j;case 2:f(T,m)}else switch(t){case 4:return!1;case 7:f(T,m)}return l?-1:o||s?s:T}};t.exports={forEach:s(0),map:s(1),filter:s(2),some:s(3),every:s(4),find:s(5),findIndex:s(6),filterReject:s(7)}},9321:function(t,r,n){var e=n(3694),o=n(7952),i=n(7806),u=o("species");t.exports=function(t){return i>=51||!e((function(){var r=[];return(r.constructor={})[u]=function(){return{foo:1}},1!==r[t](Boolean).foo}))}},2245:function(t,r,n){"use strict";var e=n(3694);t.exports=function(t,r){var n=[][t];return!!n&&e((function(){n.call(null,r||function(){return 1},1)}))}},696:function(t,r,n){var e=n(7230),o=n(2966),i=n(1646),u=n(3859),c=e.Array,a=Math.max;t.exports=function(t,r,n){for(var e=i(t),f=o(r,e),s=o(void 0===n?e:n,e),l=c(a(s-f,0)),p=0;f