diff --git a/config/forms.php b/config/forms.php index 03617f1b0df..37f94e2d256 100644 --- a/config/forms.php +++ b/config/forms.php @@ -13,6 +13,18 @@ 'forms' => resource_path('forms'), + /* + |-------------------------------------------------------------------------- + | File Uploads Path + |-------------------------------------------------------------------------- + | + | The path (on the file uploads disk) where file uploads are stored + | before they're converted to an asset or deleted. + | + */ + + 'file_uploads_path' => env('STATAMIC_FORM_UPLOADS_PATH', 'statamic/form-uploads'), + /* |-------------------------------------------------------------------------- | Email View Folder @@ -41,15 +53,12 @@ |-------------------------------------------------------------------------- | | Partial submissions are automatically deleted after a set number of days. - | Set this to null to prevent their automatic deletion. You may also enable - | garbage collection to delete related assets at the same time. + | Set this to null to prevent their automatic deletion. | */ 'delete_partial_submissions_after' => 7, - 'garbage_collect_assets' => false, - /* |-------------------------------------------------------------------------- | Exporters diff --git a/resources/js/bootstrap/fieldtypes.js b/resources/js/bootstrap/fieldtypes.js index 4c071416139..6f1c83b7936 100644 --- a/resources/js/bootstrap/fieldtypes.js +++ b/resources/js/bootstrap/fieldtypes.js @@ -34,6 +34,7 @@ import GroupFieldtype from '../components/fieldtypes/GroupFieldtype.vue'; import FormBannerFieldtype from '../components/fieldtypes/FormBannerFieldtype.vue'; import FormHeadingFieldtype from '../components/fieldtypes/FormHeadingFieldtype.vue'; import FormParagraphFieldtype from '@/components/fieldtypes/FormParagraphFieldtype.vue'; +import FormUploadFieldtype from '@/components/fieldtypes/FormUploadFieldtype.vue'; import HiddenFieldtype from '../components/fieldtypes/HiddenFieldtype.vue'; import HtmlFieldtype from '../components/fieldtypes/HtmlFieldtype.vue'; import IconFieldtype from '../components/fieldtypes/IconFieldtype.vue'; @@ -116,6 +117,7 @@ export default function registerFieldtypes(app) { app.component('form_banner-fieldtype', FormBannerFieldtype); app.component('form_heading-fieldtype', FormHeadingFieldtype); app.component('form_paragraph-fieldtype', FormParagraphFieldtype); + app.component('form_upload-fieldtype', FormUploadFieldtype); app.component('hidden-fieldtype', HiddenFieldtype); app.component('html-fieldtype', HtmlFieldtype); app.component('icon-fieldtype', IconFieldtype); diff --git a/resources/js/components/fieldtypes/FormUploadFieldtype.vue b/resources/js/components/fieldtypes/FormUploadFieldtype.vue new file mode 100644 index 00000000000..7eb7b6faf59 --- /dev/null +++ b/resources/js/components/fieldtypes/FormUploadFieldtype.vue @@ -0,0 +1,201 @@ + + + diff --git a/resources/views/forms/automagic-email.antlers.html b/resources/views/forms/automagic-email.antlers.html index 8211aa631f5..83e3cec7d1b 100644 --- a/resources/views/forms/automagic-email.antlers.html +++ b/resources/views/forms/automagic-email.antlers.html @@ -1,7 +1,7 @@ {{ fields }} {{ display }}: {{ if value }} - {{ if fieldtype == "assets" }} + {{ if (fieldtype == "form_upload" && config:store) || fieldtype == "assets" }} {{ value }} {{ permalink }} {{ if !last }},{{ /if }} diff --git a/src/Fields/Validator.php b/src/Fields/Validator.php index 394d7593795..0593bb9a755 100644 --- a/src/Fields/Validator.php +++ b/src/Fields/Validator.php @@ -77,7 +77,7 @@ private function fieldRules() } return $this->preProcessedFields()->all()->reduce(function ($carry, $field) { - if (request()->isPrecognitive() && $field->type() == 'assets') { + if (request()->isPrecognitive() && in_array($field->type(), ['assets', 'files', 'form_upload'], true)) { return $carry; } diff --git a/src/Fieldtypes/FormUpload.php b/src/Fieldtypes/FormUpload.php new file mode 100644 index 00000000000..750622fb183 --- /dev/null +++ b/src/Fieldtypes/FormUpload.php @@ -0,0 +1,142 @@ + collect(Arr::wrap($this->field->value()))->map(function (string $value): array { + $asset = $this->storesAsAsset() ? Asset::find($value) : null; + + if (! $asset) { + return ['filename' => basename($value)]; + } + + return [ + 'filename' => $asset->basename(), + 'size' => $asset->size(), + 'download_url' => $asset->cpDownloadUrl(), + ]; + })->values()->all(), + ]; + } + + public function process($values) + { + return $this->config('max_files') === 1 ? collect($values)->first() : $values; + } + + public function augment($values) + { + if (! $this->storesAsAsset()) { + return $values; + } + + $values = Arr::wrap($values); + + $ids = collect($values)->map(fn ($value) => $this->container()->handle().'::'.$value)->all(); + + $query = new OrderedQueryBuilder($this->container()->queryAssets()->whereIn('path', $values), $ids); + + return $this->config('max_files') === 1 ? $query->first() : $query; + } + + public function shallowAugment($values) + { + if (! $this->storesAsAsset()) { + return $values; + } + + $items = $this->augment($values); + $items = $this->config('max_files') === 1 ? collect([$items]) : $items->get(); + + $items = $items->filter()->map(fn ($item) => $item->toShallowAugmentedCollection()); + + return $this->config('max_files') === 1 ? $items->first() : $items; + } + + private function storesAsAsset(): bool + { + return (bool) $this->config('store'); + } + + private function container(): ?AssetContainerContract + { + if ($configured = $this->config('container')) { + if ($container = AssetContainer::find($configured)) { + return $container; + } + + throw new AssetContainerNotFoundException($configured); + } + + if (($containers = AssetContainer::all())->count() === 1) { + return $containers->first(); + } + + throw new UndefinedContainerException; + } + + public function rules(): array + { + $rules = ['array']; + + if ($max = $this->config('max_files')) { + $rules[] = 'max:'.$max; + } + + if ($min = $this->config('min_files')) { + $rules[] = 'min:'.$min; + } + + return $rules; + } + + public function fieldRules() + { + $classes = [ + 'dimensions' => DimensionsRule::class, + 'image' => ImageRule::class, + 'max_filesize' => MaxRule::class, + 'mimes' => MimesRule::class, + 'mimetypes' => MimetypesRule::class, + 'min_filesize' => MinRule::class, + ]; + + return collect(parent::fieldRules())->map(function ($rule) use ($classes) { + if (! is_string($rule)) { + return $rule; + } + + $name = Str::before($rule, ':'); + + if ($class = Arr::get($classes, $name)) { + $parameters = explode(',', Str::after($rule, ':')); + + return new $class($parameters); + } + + return $rule; + })->all(); + } +} diff --git a/src/Forms/CreateAssetsFromFileUploads.php b/src/Forms/CreateAssetsFromFileUploads.php new file mode 100644 index 00000000000..5ea726f5dee --- /dev/null +++ b/src/Forms/CreateAssetsFromFileUploads.php @@ -0,0 +1,93 @@ +submission->form()->blueprint()->fields()->all() + ->filter(fn (Field $field): bool => $field->type() === 'form_upload' && $field->fieldtype()->config('store')) + ->reduce(function (bool $created, Field $field): bool { + $paths = $this->createAssets($field); + + if (! $paths) { + return $created; + } + + $this->submission->set($field->handle(), $paths); + + return true; + }, false); + + if ($created && $this->submission->form()->store()) { + $this->submission->saveQuietly(); + } + } + + private function createAssets(Field $field): array|string|null + { + $value = $this->submission->get($field->handle()); + + if (! $value) { + return null; + } + + $assetPaths = Collection::wrap($value) + ->filter() + ->map(fn (string $path) => $this->uploadAsset($field, $path)) + ->filter() + ->values(); + + if ($assetPaths->isEmpty()) { + return null; + } + + return $field->get('max_files') === 1 ? $assetPaths->first() : $assetPaths->all(); + } + + private function uploadAsset(Field $field, string $path): ?string + { + $disk = Storage::disk(config('statamic.system.file_uploads_disk', 'local')); + $basePath = config('statamic.forms.file_uploads_path', 'statamic/form-uploads'); + $diskPath = "{$basePath}/{$this->submission->id()}/{$field->handle()}/".basename($path); + + if (! $disk->exists($diskPath)) { + return null; + } + + $uploadedFile = new UploadedFile( + $disk->path($diskPath), + basename($path), + $disk->mimeType($diskPath), + null, + true + ); + + $assetId = AssetsUploader::field($field->toArray())->upload($uploadedFile); + $assetId = is_array($assetId) ? $assetId[0] : $assetId; + + $disk->delete($diskPath); + + return Asset::findOrFail($assetId)->path(); + } +} diff --git a/src/Forms/DeleteTemporaryAttachments.php b/src/Forms/DeleteTemporaryAttachments.php deleted file mode 100644 index 5d53ee938bb..00000000000 --- a/src/Forms/DeleteTemporaryAttachments.php +++ /dev/null @@ -1,41 +0,0 @@ -submission->form()->blueprint()->fields()->all() - ->filter(fn (Field $field) => $field->type() === 'files') - ->each(function (Field $field) use ($disk, $basePath) { - Collection::wrap($this->submission->get($field->handle(), [])) - ->each(fn ($path) => $disk->delete($basePath.'/'.$path)); - - $this->submission->remove($field->handle()); - }); - - if ($this->submission->form()->store()) { - $this->submission->saveQuietly(); - } - } -} diff --git a/src/Forms/DeleteTemporaryFiles.php b/src/Forms/DeleteTemporaryFiles.php new file mode 100644 index 00000000000..15e424944a9 --- /dev/null +++ b/src/Forms/DeleteTemporaryFiles.php @@ -0,0 +1,50 @@ +submission->form()->blueprint()->fields()->all(); + $disk = Storage::disk(config('statamic.system.file_uploads_disk', 'local')); + $basePath = config('statamic.forms.file_uploads_path', 'statamic/form-uploads'); + + $fields->filter(fn (Field $field) => $field->type() === 'files')->each(function (Field $field) use ($disk): void { + $fileUploadsPath = config('statamic.system.file_uploads_path', 'statamic/file-uploads'); + + Collection::wrap($this->submission->get($field->handle(), [])) + ->reject(fn ($path) => str_contains($path, '..')) + ->each(fn ($path) => $disk->delete("{$fileUploadsPath}/".$path)); + }); + + if ($disk->exists($uploadsPath = "{$basePath}/{$this->submission->id()}")) { + $disk->deleteDirectory($uploadsPath); + } + + $removed = $fields + ->filter(fn (Field $field) => $field->type() === 'files' || ($field->type() === 'form_upload' && ! $field->fieldtype()->config('store'))) + ->each(fn (Field $field) => $this->submission->remove($field->handle())) + ->isNotEmpty(); + + if ($removed && $this->submission->form()->store()) { + $this->submission->saveQuietly(); + } + } +} diff --git a/src/Forms/Email.php b/src/Forms/Email.php index 41adbc0566e..be52427b0ba 100644 --- a/src/Forms/Email.php +++ b/src/Forms/Email.php @@ -116,10 +116,14 @@ protected function addAttachments() } $this->getRenderableFieldData(Arr::except($this->submissionData, ['id', 'date', 'form'])) - ->filter(fn ($field) => in_array($field['fieldtype'], ['assets', 'files'])) + ->filter(fn ($field) => in_array($field['fieldtype'], ['assets', 'files', 'form_upload'])) ->each(function ($field) { $field['value'] = $field['value']->value(); - $field['fieldtype'] === 'assets' ? $this->attachAssets($field) : $this->attachFiles($field); + + $isStoredAsAsset = $field['fieldtype'] === 'assets' + || ($field['fieldtype'] === 'form_upload' && Arr::get($field, 'config.store')); + + $isStoredAsAsset ? $this->attachAssets($field) : $this->attachFiles($field); }); return $this; @@ -151,7 +155,10 @@ private function attachFiles($field) } $disk = config('statamic.system.file_uploads_disk', 'local'); - $basePath = config('statamic.system.file_uploads_path', 'statamic/file-uploads'); + + $basePath = $field['fieldtype'] === 'form_upload' + ? config('statamic.forms.file_uploads_path', 'statamic/form-uploads') + : config('statamic.system.file_uploads_path', 'statamic/file-uploads'); foreach ($value as $file) { $this->attachFromStorageDisk($disk, $basePath.'/'.$file); @@ -165,7 +172,7 @@ protected function addData() $fields = $this->getRenderableFieldData(Arr::except($augmented, ['id', 'date', 'form'])) ->reject(fn ($field) => $field['fieldtype'] === 'spacer') ->when(Arr::has($this->config, 'attachments'), function ($fields) { - return $fields->reject(fn ($field) => in_array($field['fieldtype'], ['assets', 'files'])); + return $fields->reject(fn ($field) => in_array($field['fieldtype'], ['assets', 'files', 'form_upload'])); }); $formConfig = ($configFields = Form::extraConfigFor($form->handle())) ? Blueprint::makeFromTabs($configFields)->fields()->addValues($form->data()->all())->values()->all() diff --git a/src/Forms/Fieldtypes/Upload.php b/src/Forms/Fieldtypes/Upload.php index 24e3af11753..73133d967a9 100644 --- a/src/Forms/Fieldtypes/Upload.php +++ b/src/Forms/Fieldtypes/Upload.php @@ -9,7 +9,7 @@ class Upload extends FormFieldtype { - protected static $fieldtype = 'files'; + protected static $fieldtype = 'form_upload'; protected $description = 'Upload files - store them permanently or use them temporarily.'; protected $icon = 'upload-cloud'; protected $categories = ['media']; @@ -39,6 +39,12 @@ public function configFieldItems(): array 'max_items' => 1, 'if' => ['store' => true], ], + 'min_files' => [ + 'display' => __('Min Files'), + 'instructions' => __('The minimum number of files that must be uploaded.'), + 'type' => 'integer', + 'min' => 1, + ], 'max_files' => [ 'display' => __('Max Files'), 'instructions' => __('The maximum number of files that may be uploaded.'), @@ -50,20 +56,14 @@ public function configFieldItems(): array public function toFieldArray(): array { - if ($this->config('store')) { - return [ - 'type' => 'assets', - 'container' => $this->config('container'), - 'folder' => $this->config('folder'), - 'max_files' => $this->config('max_files'), - ...Arr::except($this->config(), ['type', 'store', 'container', 'folder', 'max_files']), - ]; - } - return [ - 'type' => 'files', + 'type' => 'form_upload', + 'store' => $this->config('store'), + 'container' => $this->config('container'), + 'folder' => $this->config('folder'), + 'min_files' => $this->config('min_files'), 'max_files' => $this->config('max_files'), - ...Arr::except($this->config(), ['type', 'store', 'max_files']), + ...Arr::except($this->config(), ['type', 'store', 'container', 'folder', 'min_files', 'max_files']), ]; } @@ -80,9 +80,8 @@ public function example(): ?array public function view(): string { $language = config('statamic.templates.language', 'antlers'); - - // Check for user's custom assets or files views first (for backwards compatibility) $legacyType = $this->config('store') ? 'assets' : 'files'; + $legacyViews = [ "statamic::forms.fields.{$legacyType}", "statamic::forms.{$language}.fields.{$legacyType}", diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 30dba24ce52..4bf8541c57b 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -644,7 +644,7 @@ public function editBlueprintUrl() public function hasFiles() { return $this->fields()->filter(function ($field) { - return in_array($field->fieldtype()->handle(), ['assets', 'files']); + return in_array($field->fieldtype()->handle(), ['assets', 'files', 'form_upload']); })->isNotEmpty(); } diff --git a/src/Forms/SendEmails.php b/src/Forms/SendEmails.php index 3398d566914..c2d9b3dcc22 100644 --- a/src/Forms/SendEmails.php +++ b/src/Forms/SendEmails.php @@ -40,8 +40,8 @@ private function jobs(): Collection return new $class($this->submission, $this->site, $config); }) - ->when($this->shouldDeleteTemporaryAttachments(), function ($jobs) { - $jobs->push(new DeleteTemporaryAttachments($this->submission)); + ->when($this->shouldDeleteTemporaryFiles(), function ($jobs) { + $jobs->push(new DeleteTemporaryFiles($this->submission)); }); } @@ -54,11 +54,9 @@ private function emailConfigs($submission) return collect($config); } - protected function shouldDeleteTemporaryAttachments(): bool + private function shouldDeleteTemporaryFiles(): bool { return $this->submission->form()->blueprint()->fields()->all() - ->filter(fn (Field $field) => $field->fieldtype()->handle() === 'files') - ->filter() - ->count() > 0; + ->contains(fn (Field $field) => in_array($field->type(), ['files', 'form_upload'])); } } diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php index 2432d76ee66..0999c583f5a 100644 --- a/src/Forms/Submission.php +++ b/src/Forms/Submission.php @@ -17,13 +17,13 @@ use Statamic\Events\SubmissionFinalized; use Statamic\Events\SubmissionSaved; use Statamic\Events\SubmissionSaving; -use Statamic\Facades\Asset; use Statamic\Facades\File; use Statamic\Facades\FormSubmission; use Statamic\Facades\Site as Sites; use Statamic\Facades\Stache; use Statamic\Forms\Uploaders\AssetsUploader; use Statamic\Forms\Uploaders\FilesUploader; +use Statamic\Forms\Uploaders\FormFileUpload; use Statamic\Sites\Site; use Statamic\Support\Str; use Statamic\Support\Traits\FluentlyGetsAndSets; @@ -176,7 +176,7 @@ public function status(): string } /** - * Upload files and return asset IDs. + * Upload files and return their storage references. * * @param array $uploadedFiles * @return array @@ -186,9 +186,13 @@ public function uploadFiles($uploadedFiles) return collect($uploadedFiles)->map(function ($files, $handle) { $field = $this->fields()->get($handle); - return $field['type'] === 'files' - ? FilesUploader::field($field)->upload($files) - : AssetsUploader::field($field)->upload($files); + if ($field['type'] === 'form_upload') { + return FormFileUpload::field($field, $this->id())->upload($files); + } + + return $field['type'] === 'assets' + ? AssetsUploader::field($field)->upload($files) + : FilesUploader::field($field)->upload($files); })->all(); } @@ -267,6 +271,7 @@ public function finalize() SubmissionFinalized::dispatch($this); + CreateAssetsFromFileUploads::dispatchSync($this); SendEmails::dispatch($this, $this->site()); return $this; diff --git a/src/Forms/SubmitForm.php b/src/Forms/SubmitForm.php index 792a77a6cfa..27fc1f443ca 100644 --- a/src/Forms/SubmitForm.php +++ b/src/Forms/SubmitForm.php @@ -108,7 +108,7 @@ public function submit(array $data, array $files = []): SubmissionResult private function normalizeFiles(array $files): array { $assetFields = $this->form->blueprint()->fields()->all() - ->filter(fn ($field) => in_array($field->fieldtype()->handle(), ['assets', 'files'])) + ->filter(fn ($field) => in_array($field->fieldtype()->handle(), ['assets', 'files', 'form_upload'])) ->keys(); foreach ($assetFields as $handle) { @@ -196,10 +196,9 @@ public function validate(array $data, array $files = [], ?array $only = null): v $files = $this->normalizeFiles($files); $fields = $this->form->blueprint()->fields()->addValues(array_merge($data, $files)); - $validator = $fields - ->validator() - ->withRules($this->extraRules($fields)) - ->validator(); + $validator = $fields->validator()->validator(); + + $validator->setRules($this->withFileUploadRules($validator->getRulesWithoutPlaceholders(), $fields, $files)); if (! $only && $this->page) { $only = $this->fieldHandles($this->page); @@ -212,18 +211,54 @@ public function validate(array $data, array $files = [], ?array $only = null): v $this->withLocale($this->site()?->lang(), fn () => $validator->validate()); } - private function extraRules($fields): array + private function withFileUploadRules(array $rules, $fields, array $files): array { return $fields->all() - ->filter(fn ($field): bool => in_array($field->fieldtype()->handle(), ['assets', 'files'])) - ->mapWithKeys(function ($field): array { - $rules = $field->fieldtype()->handle() === 'assets' - ? array_merge(['file', new AllowedFile], $this->assetContainerRules($field)) - : ['file', new AllowedFile($field->fieldtype()->config('allowed_extensions'))]; - - return [$field->handle().'.*' => $rules]; - }) - ->all(); + ->filter(fn ($field): bool => in_array($field->fieldtype()->handle(), ['assets', 'files', 'form_upload'])) + ->reduce(function ($rules, $field) use ($files) { + $handle = $field->handle(); + + // Freshly uploaded files should be validated as files. + if (array_key_exists($handle, $files)) { + $shouldBeStoredAsAsset = $field->fieldtype()->handle() === 'assets' + || ($field->fieldtype()->handle() === 'form_upload' && $field->fieldtype()->config('store')); + + $fileRules = $shouldBeStoredAsAsset + ? array_merge(['file', new AllowedFile], $this->assetContainerRules($field)) + : ['file', new AllowedFile($field->fieldtype()->config('allowed_extensions'))]; + + $rules["{$handle}.*"] = collect($rules["{$handle}.*"] ?? []) + ->merge($fileRules) + ->unique() + ->values() + ->all(); + + return $rules; + } + + // Anything not present in $files must already be stored against the field. Removing + // some of an existing multi-file value is fine, but a value that was never uploaded + // gets rejected - an unvalidated one could be used to read/delete arbitrary files + // when the submission is finalized. + $stored = Arr::wrap($this->submission?->get($handle)); + + if (collect(Arr::wrap($field->value()))->contains(fn ($value) => ! in_array($value, $stored, true))) { + $rules[$handle] = array_merge($rules[$handle] ?? [], ['prohibited']); + + return $rules; + } + + if (is_array($field->value()) || ! isset($rules[$handle])) { + return $rules; + } + + $rules[$handle] = collect($rules[$handle]) + ->reject(fn ($rule) => $rule === 'array' || str_starts_with($rule, 'max:') || str_starts_with($rule, 'min:')) + ->values() + ->all(); + + return $rules; + }, $rules); } private function assetContainerRules($field): array diff --git a/src/Forms/Uploaders/FormFileUpload.php b/src/Forms/Uploaders/FormFileUpload.php new file mode 100644 index 00000000000..94f5788c582 --- /dev/null +++ b/src/Forms/Uploaders/FormFileUpload.php @@ -0,0 +1,76 @@ +config = collect($config); + } + + /** + * Instantiate form file upload. + * + * @param array $config + * @return static + */ + public static function field($config, string $submissionId) + { + return new static($config, $submissionId); + } + + /** + * Upload the files and return their storage paths. + * + * @param mixed $files + * @return array|string + */ + public function upload($files) + { + $paths = $this->getUploadableFiles($files)->map(function ($file) { + return FormFileUploader::submission($this->submissionId, $this->config->get('handle'), $this->config->get('container'))->upload($file); + }); + + return $this->isSingleFile() + ? $paths->first() + : $paths->all(); + } + + /** + * Get uploadable files. + * + * @param mixed $files + * @return \Illuminate\Support\Collection + */ + protected function getUploadableFiles($files) + { + $files = collect(Arr::wrap($files))->filter(); + + return $this->isSingleFile() + ? $files->take(1) + : $files; + } + + /** + * Determine if uploader should only upload a single file. + * + * @return bool + */ + protected function isSingleFile() + { + return $this->config->get('max_files') === 1; + } +} diff --git a/src/Forms/Uploaders/FormFileUploader.php b/src/Forms/Uploaders/FormFileUploader.php new file mode 100644 index 00000000000..56c371f1076 --- /dev/null +++ b/src/Forms/Uploaders/FormFileUploader.php @@ -0,0 +1,48 @@ +submissionId = $submissionId; + $uploader->handle = $handle; + + return $uploader; + } + + protected function uploadPath(UploadedFile $file) + { + $directory = "{$this->submissionId}/{$this->handle}"; + + return "{$directory}/".$this->uniqueFilename($directory, $file->getClientOriginalName()); + } + + private function uniqueFilename(string $directory, string $filename, int $count = 0): string + { + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $basename = pathinfo($filename, PATHINFO_FILENAME); + $suffix = $count ? "-{$count}" : ''; + $candidate = $basename.$suffix.($extension ? ".{$extension}" : ''); + + if ($this->disk()->exists($this->uploadPathPrefix()."{$directory}/{$candidate}")) { + return $this->uniqueFilename($directory, $filename, $count + 1); + } + + return $candidate; + } + + protected function uploadPathPrefix() + { + return config('statamic.forms.file_uploads_path', 'statamic/form-uploads').'/'; + } +} diff --git a/src/Jobs/DeletePartialFormSubmissions.php b/src/Jobs/DeletePartialFormSubmissions.php index df116781cbe..fefbea040a1 100644 --- a/src/Jobs/DeletePartialFormSubmissions.php +++ b/src/Jobs/DeletePartialFormSubmissions.php @@ -7,8 +7,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Statamic\Contracts\Forms\Submission; -use Statamic\Facades\Asset; use Statamic\Facades\FormSubmission; +use Statamic\Forms\DeleteTemporaryFiles; class DeletePartialFormSubmissions implements ShouldQueue { @@ -27,24 +27,9 @@ public function handle(): void ->where('date', '<', $threshold) ->get() ->each(function (Submission $submission): void { - if (config('statamic.forms.garbage_collect_assets')) { - $this->garbageCollectAssets($submission); - } + DeleteTemporaryFiles::dispatchSync($submission); $submission->delete(); }); } - - private function garbageCollectAssets(Submission $submission): void - { - $submission->form()->blueprint()->fields()->all() - ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets') - ->each(function ($field) use ($submission) { - $container = $field->get('container'); - - collect($submission->get($field->handle())) - ->filter() - ->each(fn ($path) => Asset::find("{$container}::{$path}")?->delete()); - }); - } } diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index f06b5fa4f16..964a4af6dc8 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -94,6 +94,7 @@ class ExtensionServiceProvider extends ServiceProvider Fieldtypes\FormBanner::class, Fieldtypes\FormHeading::class, Fieldtypes\FormParagraph::class, + Fieldtypes\FormUpload::class, Fieldtypes\Hidden::class, Fieldtypes\Html::class, Fieldtypes\Icon::class, diff --git a/tests/Forms/CreateAssetsFromFileUploadsTest.php b/tests/Forms/CreateAssetsFromFileUploadsTest.php new file mode 100644 index 00000000000..f9e2dbb6e47 --- /dev/null +++ b/tests/Forms/CreateAssetsFromFileUploadsTest.php @@ -0,0 +1,240 @@ +fakeAvatarsContainer(); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $path = FormFileUpload::field(['handle' => 'avatar', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->image('avatar.jpg')]); + + $submission->set('avatar', $path)->save(); + + (new CreateAssetsFromFileUploads($submission))->handle(); + + Storage::disk('local')->assertMissing('statamic/form-uploads/'.$path); + Storage::disk('avatars')->assertExists('avatar.jpg'); + + $newValue = $form->submission($submission->id())->get('avatar'); + $this->assertIsString($newValue); + $this->assertNotEquals($path, $newValue); + $this->assertNotNull(Asset::find("avatars::{$newValue}")); + } + + #[Test] + public function it_creates_an_asset_from_a_temporary_file_on_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.forms.file_uploads_path' => 'temp-form-uploads', + ]); + + Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + $this->fakeAvatarsContainer(); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $path = FormFileUpload::field(['handle' => 'avatar', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->image('avatar.jpg')]); + + $submission->set('avatar', $path)->save(); + + (new CreateAssetsFromFileUploads($submission))->handle(); + + $uploadsDisk->assertMissing('temp-form-uploads/'.$path); + Storage::disk('avatars')->assertExists('avatar.jpg'); + + $this->assertNotNull(Asset::find('avatars::'.$form->submission($submission->id())->get('avatar'))); + } + + #[Test] + public function it_creates_assets_from_multiple_temporary_files() + { + Storage::fake('local'); + $this->fakeAvatarsContainer(); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'photos', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 3]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $paths = FormFileUpload::field(['handle' => 'photos', 'max_files' => 3], $submission->id()) + ->upload([UploadedFile::fake()->image('one.jpg'), UploadedFile::fake()->image('two.jpg')]); + + $submission->set('photos', $paths)->save(); + + (new CreateAssetsFromFileUploads($submission))->handle(); + + Storage::disk('avatars')->assertExists('one.jpg'); + Storage::disk('avatars')->assertExists('two.jpg'); + + $newValue = $form->submission($submission->id())->get('photos'); + $this->assertCount(2, $newValue); + $this->assertNotNull(Asset::find("avatars::{$newValue[0]}")); + $this->assertNotNull(Asset::find("avatars::{$newValue[1]}")); + } + + #[Test] + public function it_only_creates_assets_when_storage_is_enabled_on_upload_field() + { + Storage::fake('local'); + $this->fakeAvatarsContainer(); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false, 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $avatarPath = FormFileUpload::field(['handle' => 'avatar', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->image('avatar.jpg')]); + $documentPath = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + $submission->set('avatar', $avatarPath)->set('document', $documentPath)->save(); + + (new CreateAssetsFromFileUploads($submission))->handle(); + + // `avatar` (store: true) is promoted to a real asset... + Storage::disk('local')->assertMissing('statamic/form-uploads/'.$avatarPath); + Storage::disk('avatars')->assertExists('avatar.jpg'); + $newAvatarValue = $form->submission($submission->id())->get('avatar'); + $this->assertNotEquals($avatarPath, $newAvatarValue); + $this->assertNotNull(Asset::find("avatars::{$newAvatarValue}")); + + // ...but `document` (store: false) is left exactly as it was. + Storage::disk('local')->assertExists('statamic/form-uploads/'.$documentPath); + $this->assertEquals($documentPath, $form->submission($submission->id())->get('document')); + } + + #[Test] + public function it_skips_creating_an_asset_when_the_temporary_file_doesnt_exist() + { + Storage::fake('local'); + $this->fakeAvatarsContainer(); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission()->set('avatar', 'never-existed.jpg'))->save(); + + (new CreateAssetsFromFileUploads($submission))->handle(); + + $this->assertEquals('never-existed.jpg', $form->submission($submission->id())->get('avatar')); + $this->assertEmpty(AssetContainer::find('avatars')->assets()); + } + + #[Test] + public function it_never_reads_or_deletes_outside_the_submissions_own_directory() + { + Storage::fake('local'); + $this->fakeAvatarsContainer(); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ]))->save(); + + Storage::disk('local')->put('framework/sessions/secret', 'sensitive'); + + $submission = tap($form->makeSubmission()->set('avatar', '../../framework/sessions/secret'))->save(); + + (new CreateAssetsFromFileUploads($submission))->handle(); + + Storage::disk('local')->assertExists('framework/sessions/secret'); + $this->assertEmpty(AssetContainer::find('avatars')->assets()); + } + + #[Test] + public function it_still_creates_the_asset_when_the_form_does_not_store_submissions() + { + Storage::fake('local'); + $this->fakeAvatarsContainer(); + + $form = tap(Form::make('contact')->store(false)->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = $form->makeSubmission(); + + $path = FormFileUpload::field(['handle' => 'avatar', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->image('avatar.jpg')]); + + $submission->set('avatar', $path); + + (new CreateAssetsFromFileUploads($submission))->handle(); + + // The asset is still created and the temporary copy still cleaned up... + Storage::disk('avatars')->assertExists('avatar.jpg'); + Storage::disk('local')->assertMissing('statamic/form-uploads/'.$path); + + // ...but since this form never persists submissions, there's nothing to save it to. + $this->assertNull($form->submission($submission->id())); + $this->assertNotEquals($path, $submission->get('avatar')); + } + + private function fakeAvatarsContainer(): void + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + } +} diff --git a/tests/Forms/DeletePartialFormSubmissionsTest.php b/tests/Forms/DeletePartialFormSubmissionsTest.php index bccddacc7d2..65c58a7cecc 100644 --- a/tests/Forms/DeletePartialFormSubmissionsTest.php +++ b/tests/Forms/DeletePartialFormSubmissionsTest.php @@ -3,11 +3,10 @@ namespace Tests\Forms; use Carbon\Carbon; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Bus; use PHPUnit\Framework\Attributes\Test; -use Statamic\Facades\AssetContainer; use Statamic\Facades\Form; +use Statamic\Forms\DeleteTemporaryFiles; use Statamic\Jobs\DeletePartialFormSubmissions; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -87,66 +86,21 @@ public function it_does_not_delete_anything_when_disabled() } #[Test] - public function it_deletes_attached_assets_when_garbage_collection_is_enabled() + public function it_dispatches_delete_temporary_files_for_each_abandoned_partial_submission() { - config([ - 'statamic.forms.delete_partial_submissions_after' => 7, - 'statamic.forms.garbage_collect_assets' => true, - ]); + Bus::fake(); - $form = $this->formWithUploadField(); - $partial = $this->partialWithAsset($form); - - Storage::disk('avatars')->assertExists('avatar.jpg'); - - (new DeletePartialFormSubmissions)->handle(); - - $this->assertNull($form->submission($partial->id())); - Storage::disk('avatars')->assertMissing('avatar.jpg'); - } - - #[Test] - public function it_leaves_attached_assets_when_garbage_collection_is_disabled() - { - config([ - 'statamic.forms.delete_partial_submissions_after' => 7, - 'statamic.forms.garbage_collect_assets' => false, - ]); - - $form = $this->formWithUploadField(); - $partial = $this->partialWithAsset($form); - - (new DeletePartialFormSubmissions)->handle(); - - // The partial is still deleted, but its asset is left untouched. - $this->assertNull($form->submission($partial->id())); - Storage::disk('avatars')->assertExists('avatar.jpg'); - } - - private function formWithUploadField() - { - Storage::fake('avatars'); - tap(AssetContainer::make('avatars')->disk('avatars'))->save(); - - return tap(Form::make('contact')->formFields([ - 'sections' => [ - ['fields' => [ - ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']], - ]], - ], - ]))->save(); - } + config(['statamic.forms.delete_partial_submissions_after' => 7]); - private function partialWithAsset($form) - { - $asset = tap(AssetContainer::find('avatars')->makeAsset('avatar.jpg')) - ->upload(UploadedFile::fake()->image('avatar.jpg')); + $form = tap(Form::make('contact'))->save(); Carbon::setTestNow('2025-06-01 12:00:00'); - $partial = tap($form->makeSubmission()->set('partial', true)->set('avatar', [$asset->path()]))->save(); + tap($form->makeSubmission()->set('partial', true))->save(); Carbon::setTestNow('2025-06-30 12:00:00'); - return $partial; + (new DeletePartialFormSubmissions)->handle(); + + Bus::assertDispatchedSync(DeleteTemporaryFiles::class); } } diff --git a/tests/Forms/DeleteTemporaryFilesTest.php b/tests/Forms/DeleteTemporaryFilesTest.php new file mode 100644 index 00000000000..8edcb885265 --- /dev/null +++ b/tests/Forms/DeleteTemporaryFilesTest.php @@ -0,0 +1,244 @@ +formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'max_files' => 1]], + ['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false, 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $avatarPath = FormFileUpload::field(['handle' => 'avatar', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->image('avatar.jpg')]); + $documentPath = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + $submission->set('avatar', $avatarPath)->set('document', $documentPath)->save(); + + (new DeleteTemporaryFiles($submission))->handle(); + + Storage::disk('local')->assertMissing('statamic/form-uploads/'.$avatarPath); + Storage::disk('local')->assertMissing('statamic/form-uploads/'.$documentPath); + Storage::disk('local')->assertDirectoryEmpty('statamic/form-uploads/'.$submission->id()); + } + + #[Test] + public function it_deletes_the_temporary_folder_for_a_form_with_only_store_true_fields() + { + Storage::fake('local'); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + // Simulates an abandoned partial: the upload landed in temporary storage but was never + // promoted to a real asset because the submission was never finalized. + $avatarPath = FormFileUpload::field(['handle' => 'avatar', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->image('avatar.jpg')]); + + $submission->set('avatar', $avatarPath)->save(); + + (new DeleteTemporaryFiles($submission))->handle(); + + Storage::disk('local')->assertMissing('statamic/form-uploads/'.$avatarPath); + Storage::disk('local')->assertDirectoryEmpty('statamic/form-uploads/'.$submission->id()); + } + + #[Test] + public function it_removes_store_false_field_values_but_keeps_store_true_ones() + { + Storage::fake('local'); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'max_files' => 1]], + ['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false, 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $documentPath = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + // Simulate the normal finalize order: `avatar` already promoted to a real asset path by + // `CreateAssetsFromFileUploads` before this job runs; `document` is still temporary. + $submission->set('avatar', 'uploads/avatar.jpg')->set('document', $documentPath)->save(); + + (new DeleteTemporaryFiles($submission))->handle(); + + $this->assertEquals('uploads/avatar.jpg', $form->submission($submission->id())->get('avatar')); + $this->assertNull($form->submission($submission->id())->get('document')); + } + + #[Test] + public function it_does_not_save_when_the_form_does_not_store_submissions() + { + Storage::fake('local'); + + $form = tap(Form::make('contact')->store(false)->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false, 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = $form->makeSubmission(); + + $documentPath = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + $submission->set('document', $documentPath); + + (new DeleteTemporaryFiles($submission))->handle(); + + $this->assertNull($form->submission($submission->id())); + } + + #[Test] + public function it_deletes_temp_files_created_by_the_files_fieldtype() + { + Storage::fake('local'); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'document', 'field' => ['type' => 'files']], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $path = now()->timestamp.'/resume.pdf'; + Storage::disk('local')->put('statamic/file-uploads/'.$path, ''); + $submission->set('document', [$path])->save(); + + (new DeleteTemporaryFiles($submission))->handle(); + + Storage::disk('local')->assertMissing('statamic/file-uploads/'.$path); + $this->assertNull($form->submission($submission->id())->get('document')); + } + + #[Test] + public function it_deletes_the_temporary_storage_folder_from_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.forms.file_uploads_path' => 'temp-form-uploads', + ]); + + $localDisk = Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false, 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $path = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + $submission->set('document', $path)->save(); + + $localDisk->put('temp-form-uploads/'.$path, 'contents'); + + (new DeleteTemporaryFiles($submission))->handle(); + + $uploadsDisk->assertMissing('temp-form-uploads/'.$path); + $localDisk->assertExists('temp-form-uploads/'.$path); + } + + #[Test] + public function it_deletes_files_fieldtype_uploads_from_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.system.file_uploads_path' => 'temp-uploads', + ]); + + $localDisk = Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'document', 'field' => ['type' => 'files', 'max_files' => 1]], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission())->save(); + + $path = now()->timestamp.'/resume.pdf'; + $uploadsDisk->put('temp-uploads/'.$path, 'contents'); + $localDisk->put('temp-uploads/'.$path, 'contents'); + + $submission->set('document', [$path])->save(); + + (new DeleteTemporaryFiles($submission))->handle(); + + $uploadsDisk->assertMissing('temp-uploads/'.$path); + $localDisk->assertExists('temp-uploads/'.$path); + } + + #[Test] + public function it_does_not_delete_asset_files() + { + Storage::fake('local'); + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'assets', 'container' => 'avatars']], + ]], + ], + ]))->save(); + + $submission = tap($form->makeSubmission()->set('avatar', ['avatar.jpg']))->save(); + + (new DeleteTemporaryFiles($submission))->handle(); + + $this->assertEquals(['avatar.jpg'], $form->submission($submission->id())->get('avatar')); + } +} diff --git a/tests/Forms/EmailTest.php b/tests/Forms/EmailTest.php index a2a693c3e68..c9d40b82f35 100644 --- a/tests/Forms/EmailTest.php +++ b/tests/Forms/EmailTest.php @@ -3,15 +3,20 @@ namespace Tests\Forms; use Facades\Statamic\Fields\BlueprintRepository; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Mockery; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Asset; +use Statamic\Facades\AssetContainer; use Statamic\Facades\Blueprint; use Statamic\Facades\Form; use Statamic\Facades\GlobalSet; use Statamic\Facades\Site; use Statamic\Forms\Email; use Statamic\Forms\Submission; +use Statamic\Forms\Uploaders\FormFileUpload; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -236,7 +241,141 @@ public function it_escapes_submitted_values_in_the_automagic_email() #[Test] public function attachments_are_added() { - $this->markTestIncomplete(); + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + // "store: true" means that the uploaded file is now an asset. + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ], + ]))->save(); + + tap(Asset::make()->container('avatars')->path('avatar.jpg'))->save(); + + $submission = $form->makeSubmission()->data(['avatar' => 'avatar.jpg']); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'attachments' => true], Site::default()))->build(); + + $this->assertTrue($email->hasAttachmentFromStorageDisk('avatars', 'avatar.jpg')); + } + + #[Test] + public function it_attaches_temporary_file_upload() + { + Storage::fake('local'); + + // "store: false" means that the uploaded file is temporary. + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false, 'max_files' => 1]], + ], + ]))->save(); + + $submission = $form->makeSubmission(); + $path = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + $submission->data(['document' => $path]); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'attachments' => true], Site::default()))->build(); + + $this->assertTrue($email->hasAttachmentFromStorageDisk('local', 'statamic/form-uploads/'.$path)); + } + + #[Test] + public function it_attaches_temporary_file_upload_from_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.forms.file_uploads_path' => 'temp-form-uploads', + ]); + + Storage::fake('local'); + Storage::fake('uploads'); + + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false, 'max_files' => 1]], + ], + ]))->save(); + + $submission = $form->makeSubmission(); + $path = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], $submission->id()) + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + $submission->data(['document' => $path]); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'attachments' => true], Site::default()))->build(); + + $this->assertTrue($email->hasAttachmentFromStorageDisk('uploads', 'temp-form-uploads/'.$path)); + } + + #[Test] + public function it_attaches_files_from_assets_field() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'assets', 'container' => 'avatars', 'max_files' => 1]], + ], + ]))->save(); + + tap(Asset::make()->container('avatars')->path('avatar.jpg'))->save(); + + $submission = $form->makeSubmission()->data(['avatar' => 'avatar.jpg']); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'attachments' => true], Site::default()))->build(); + + $this->assertTrue($email->hasAttachmentFromStorageDisk('avatars', 'avatar.jpg')); + } + + #[Test] + public function it_attaches_files_from_files_field() + { + Storage::fake('local'); + + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'document', 'field' => ['type' => 'files', 'max_files' => 1]], + ], + ]))->save(); + + $documentPath = now()->timestamp.'/resume.pdf'; + Storage::disk('local')->put('statamic/file-uploads/'.$documentPath, 'contents'); + + $submission = $form->makeSubmission()->data(['document' => $documentPath]); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'attachments' => true], Site::default()))->build(); + + $this->assertTrue($email->hasAttachmentFromStorageDisk('local', 'statamic/file-uploads/'.$documentPath)); + } + + #[Test] + public function it_attaches_files_from_files_field_on_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.system.file_uploads_path' => 'temp-uploads', + ]); + + Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'document', 'field' => ['type' => 'files', 'max_files' => 1]], + ], + ]))->save(); + + $documentPath = now()->timestamp.'/resume.pdf'; + $uploadsDisk->put('temp-uploads/'.$documentPath, 'contents'); + + $submission = $form->makeSubmission()->data(['document' => $documentPath]); + + $email = tap(new Email($submission, ['to' => 'test@test.com', 'attachments' => true], Site::default()))->build(); + + $this->assertTrue($email->hasAttachmentFromStorageDisk('uploads', 'temp-uploads/'.$documentPath)); } #[Test] diff --git a/tests/Forms/Fieldtypes/UploadTest.php b/tests/Forms/Fieldtypes/UploadTest.php new file mode 100644 index 00000000000..b455df2a727 --- /dev/null +++ b/tests/Forms/Fieldtypes/UploadTest.php @@ -0,0 +1,71 @@ +setField(new FormField('avatar', [ + 'type' => 'upload', + 'store' => true, + 'container' => 'avatars', + 'folder' => 'uploads', + 'max_files' => 1, + ])); + + $this->assertEquals([ + 'type' => 'form_upload', + 'store' => true, + 'container' => 'avatars', + 'folder' => 'uploads', + 'min_files' => null, + 'max_files' => 1, + ], $fieldtype->toFieldArray()); + } + + #[Test] + public function it_shows_the_real_filename_for_a_stored_asset() + { + Storage::fake('test'); + Storage::disk('test')->put('avatar.jpg', ''); + AssetContainer::make('avatars')->disk('test')->save(); + + $field = (new Field('avatar', ['type' => 'form_upload', 'store' => true, 'container' => 'avatars'])) + ->setValue(['avatars::avatar.jpg']); + + $fieldtype = (new FormUpload)->setField($field); + + $files = $fieldtype->preload()['files']; + + $this->assertEquals('avatar.jpg', $files[0]['filename']); + } + + #[Test] + public function it_strips_the_temporary_folder_from_a_temp_files_display_filename() + { + $field = (new Field('document', ['type' => 'form_upload', 'store' => false])) + ->setValue(['1784026119/resume.pdf']); + + $fieldtype = (new FormUpload)->setField($field); + + $this->assertEquals([ + 'files' => [ + ['filename' => 'resume.pdf'], + ], + ], $fieldtype->preload()); + } +} diff --git a/tests/Forms/SendEmailsTest.php b/tests/Forms/SendEmailsTest.php index 5b7280b0d77..60e62577357 100644 --- a/tests/Forms/SendEmailsTest.php +++ b/tests/Forms/SendEmailsTest.php @@ -6,10 +6,9 @@ use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use Statamic\Contracts\Forms\SubmissionRepository; use Statamic\Facades\Form as FacadesForm; use Statamic\Facades\Site; -use Statamic\Forms\DeleteTemporaryAttachments; +use Statamic\Forms\DeleteTemporaryFiles; use Statamic\Forms\SendEmail; use Statamic\Forms\SendEmails; use Tests\PreventSavingStacheItemsToDisk; @@ -90,7 +89,7 @@ public function it_queues_email_jobs_when_config_contains_single_email() } #[Test] - public function it_dispatches_delete_attachments_job_after_dispatching_email_jobs() + public function it_dispatches_delete_temporary_files_job_after_dispatching_email_jobs() { Bus::fake(); @@ -100,7 +99,7 @@ public function it_dispatches_delete_attachments_job_after_dispatching_email_job 'foo' => 'bar', ])->formFields([ 'fields' => [ - ['handle' => 'attachments', 'field' => ['type' => 'files']], + ['handle' => 'document', 'field' => ['type' => 'form_upload', 'store' => false]], ], ]))->save(); @@ -115,31 +114,27 @@ public function it_dispatches_delete_attachments_job_after_dispatching_email_job 'to' => 'first@recipient.com', 'foo' => 'bar', ]), - new DeleteTemporaryAttachments($submission), + new DeleteTemporaryFiles($submission), ]); } #[Test] - public function delete_attachments_job_only_saves_submission_when_enabled() + public function it_dispatches_delete_temporary_files_job_even_without_any_emails_configured() { - $form = tap(FacadesForm::make('attachments_test')->email([ - 'from' => 'first@sender.com', - 'to' => 'first@recipient.com', - 'foo' => 'bar', - ]))->save(); - - $form - ->store(false) - ->blueprint() - ->ensureField('attachments', ['type' => 'files'])->save(); - - $submission = $form->makeSubmission(); + Bus::fake(); - (new DeleteTemporaryAttachments($submission))->handle(); + $form = tap(FacadesForm::make('attachments_test')->formFields([ + 'fields' => [ + ['handle' => 'document', 'field' => ['type' => 'form_upload', 'store' => false]], + ], + ]))->save(); - $submissions = app(SubmissionRepository::class)->all(); + (new SendEmails( + $form->makeSubmission(), + Site::default(), + ))->handle(); - $this->assertEmpty($submissions); + Bus::assertDispatched(DeleteTemporaryFiles::class); } #[Test] @@ -164,7 +159,7 @@ public function delete_attachments_job_deletes_files_from_the_configured_disk_an $submission = $form->makeSubmission()->data(['attachments' => ['1234567/file.txt']]); - (new DeleteTemporaryAttachments($submission))->handle(); + (new DeleteTemporaryFiles($submission))->handle(); $uploadsDisk->assertMissing('temp-uploads/1234567/file.txt'); $localDisk->assertExists('statamic/file-uploads/1234567/file.txt'); diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php index c2229205e4f..b6a2200b7a2 100644 --- a/tests/Forms/SubmissionTest.php +++ b/tests/Forms/SubmissionTest.php @@ -16,6 +16,7 @@ use Statamic\Events\SubmissionSaving; use Statamic\Facades\Form; use Statamic\Facades\Site; +use Statamic\Forms\CreateAssetsFromFileUploads; use Statamic\Forms\SendEmails; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -381,11 +382,26 @@ public function finalizing_a_new_submission_dispatches_created_and_finalized_eve Event::assertDispatched(SubmissionCreated::class, 1); Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(CreateAssetsFromFileUploads::class, 1); Bus::assertDispatched(SendEmails::class, 1); $this->assertNotNull($form->submission($submission->id())); } + #[Test] + public function finalizing_dispatches_asset_creation_synchronously_then_sends_emails() + { + Bus::fake(); + + $form = tap(Form::make('contact_us'))->save(); + $submission = $form->makeSubmission()->asPartial(); + + $submission->finalize(); + + Bus::assertDispatchedSync(CreateAssetsFromFileUploads::class); + Bus::assertDispatched(SendEmails::class); + } + #[Test] public function finalizing_a_partial_submission_removes_the_status_key_and_dispatches_events() { @@ -403,6 +419,7 @@ public function finalizing_a_partial_submission_removes_the_status_key_and_dispa // existing submission won't dispatch it again, but it does finalize and email. Event::assertDispatched(SubmissionCreated::class, 1); Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(CreateAssetsFromFileUploads::class, 1); Bus::assertDispatched(SendEmails::class, 1); } @@ -419,6 +436,7 @@ public function finalizing_a_submission_for_a_non_storing_form_still_dispatches_ Event::assertDispatched(SubmissionCreated::class, 1); Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(CreateAssetsFromFileUploads::class, 1); Bus::assertDispatched(SendEmails::class, 1); $this->assertNull($form->submission($submission->id())); } @@ -440,6 +458,7 @@ public function finalizing_a_submission_for_a_non_storing_form_deletes_it() Event::assertDispatched(SubmissionCreated::class, 1); Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(CreateAssetsFromFileUploads::class, 1); Bus::assertDispatched(SendEmails::class, 1); Event::assertNotDispatched(SubmissionDeleted::class); } @@ -458,6 +477,7 @@ public function finalizing_is_idempotent() // The second call is a no-op because the submission is no longer partial. Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(CreateAssetsFromFileUploads::class, 1); Bus::assertDispatched(SendEmails::class, 1); } diff --git a/tests/Forms/SubmitFormTest.php b/tests/Forms/SubmitFormTest.php index c06f247406e..30496505407 100644 --- a/tests/Forms/SubmitFormTest.php +++ b/tests/Forms/SubmitFormTest.php @@ -17,6 +17,7 @@ use Statamic\Facades\AssetContainer; use Statamic\Facades\Fieldset; use Statamic\Facades\Form; +use Statamic\Forms\CreateAssetsFromFileUploads; use Statamic\Forms\SendEmails; use Statamic\Forms\SubmissionResult; use Statamic\Forms\SubmitForm; @@ -165,6 +166,7 @@ public function it_finalizes_without_storing_when_store_is_disabled() $this->assertEmpty($this->form->submissions()); Event::assertDispatched(SubmissionCreated::class); Event::assertDispatched(SubmissionFinalized::class); + Bus::assertDispatched(CreateAssetsFromFileUploads::class); Bus::assertDispatched(SendEmails::class); } @@ -307,9 +309,363 @@ public function it_uploads_files() $form->submissions()->each->delete(); } + #[Test] + public function it_uploads_files_via_assets_fieldtype() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('avatar.png')->formFields([ + 'fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'avatar', 'field' => ['type' => 'assets', 'container' => 'avatars', 'max_files' => 1]], + ], + ]))->save(); + + $result = app(SubmitForm::class) + ->form($form) + ->submit( + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + + Storage::disk('avatars')->assertExists('avatar.jpg'); + $this->assertEquals('avatar.jpg', $result->submission->get('avatar')); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_uploads_files_via_files_fieldtype() + { + Storage::fake('local'); + Bus::fake(); // Otherwise finalize's queued cleanup job removes it before we can look. + + $form = tap(Form::make('avatar.png')->formFields([ + 'fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'avatar', 'field' => ['type' => 'files', 'max_files' => 1]], + ], + ]))->save(); + + $result = app(SubmitForm::class) + ->form($form) + ->submit( + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->create('resume.pdf', 10)]], + ); + + $path = $result->submission->get('avatar'); + + $this->assertNotNull($path); + Storage::disk('local')->assertExists('statamic/file-uploads/'.$path); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_does_not_revalidate_already_uploaded_files_when_resubmitting_their_page() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']], + ['handle' => 'headshot', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ], + ], + ]))->save(); + + $first = app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit( + data: ['email' => 'test@example.com'], + files: [ + 'avatar' => [UploadedFile::fake()->image('avatar.jpg')], + 'headshot' => [UploadedFile::fake()->image('headshot.jpg')], + ], + ); + + // Going back to the page and resubmitting resends each field's already-resolved value: + // an array for `avatar` (no max_files), a plain string for `headshot` (max_files: 1). + // Neither should be revalidated as though it were a fresh upload. + $result = app(SubmitForm::class) + ->form($form) + ->page('main') + ->resume($first->submission) + ->submit(data: [ + 'email' => 'test@example.com', + 'avatar' => $first->submission->get('avatar'), + 'headshot' => $first->submission->get('headshot'), + ]); + + $this->assertTrue($result->isFinalized()); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_still_validates_a_freshly_uploaded_file_when_resubmitting_its_page() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ], + ], + ]))->save(); + + $first = app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit( + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + + // Resubmitting with a genuinely new file should still be validated as a fresh upload, + // not skipped the way an already-uploaded field's carried-over value is. + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->resume($first->submission) + ->submit( + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->create('virus.php', 10)]], + ); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('avatar.0', $e->errors()); + } + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_still_validates_min_files_when_removing_files_without_uploading_a_new_one() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'gallery', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'min_files' => 2]], + ]], + ], + ], + ], + ]))->save(); + + $first = app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit( + data: ['email' => 'test@example.com'], + files: ['gallery' => [UploadedFile::fake()->image('one.jpg'), UploadedFile::fake()->image('two.jpg')]], + ); + + // Dropping one of the two files, without uploading a replacement, still leaves an + // array-shaped value. It should keep being validated against min_files as normal. + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->resume($first->submission) + ->submit(data: ['email' => 'test@example.com', 'gallery' => [$first->submission->get('gallery')[0]]]); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('gallery', $e->errors()); + } + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_allows_removing_a_file_from_a_multi_file_upload_when_resubmitting_its_page() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'gallery', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']], + ]], + ], + ], + ], + ]))->save(); + + $first = app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit( + data: ['email' => 'test@example.com'], + files: ['gallery' => [UploadedFile::fake()->image('one.jpg'), UploadedFile::fake()->image('two.jpg')]], + ); + + // Dropping one of the two already-stored files, without uploading a replacement, is a valid + // subset of what's stored - it shouldn't be rejected as a forged value. + $result = app(SubmitForm::class) + ->form($form) + ->page('main') + ->resume($first->submission) + ->submit(data: [ + 'email' => 'test@example.com', + 'gallery' => [$first->submission->get('gallery')[0]], + ]); + + $this->assertTrue($result->isFinalized()); + $this->assertEquals([$first->submission->get('gallery')[0]], $result->submission->get('gallery')); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_rejects_an_upload_value_that_was_never_actually_uploaded() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ], + ], + ]))->save(); + + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit(data: ['email' => 'test@example.com', 'avatar' => '../../framework/sessions/x']); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('avatar', $e->errors()); + } + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_rejects_a_forged_multi_file_upload_value() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'gallery', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']], + ]], + ], + ], + ], + ]))->save(); + + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit(data: ['email' => 'test@example.com', 'gallery' => ['../../framework/sessions/x']]); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('gallery', $e->errors()); + } + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_rejects_a_resubmitted_upload_value_that_doesnt_match_whats_stored() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars', 'max_files' => 1]], + ]], + ], + ], + ], + ]))->save(); + + $first = app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit( + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->resume($first->submission) + ->submit(data: ['email' => 'test@example.com', 'avatar' => '../../framework/sessions/x']); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('avatar', $e->errors()); + } + + $form->submissions()->each->delete(); + } + #[Test] public function it_validates_the_extension_of_uploaded_files() { + Bus::fake(); // Otherwise the temp file is deleted by DeleteTemporaryFiles right after submission. Storage::fake('local'); // store: false makes this a temporary "files" upload rather than a stored asset. @@ -343,6 +699,7 @@ public function it_validates_the_extension_of_uploaded_files() ->submit(data: [], files: ['document' => [UploadedFile::fake()->create('resume.pdf', 10)]]); $this->assertTrue($result->isFinalized()); + Storage::disk('local')->assertExists('statamic/form-uploads/'.$result->submission->get('document')[0]); $form->submissions()->each->delete(); } @@ -446,6 +803,7 @@ public function it_returns_the_next_page_and_saves_a_partial_submission_when_sub Event::assertDispatched(SubmissionCreated::class); Event::assertNotDispatched(FormSubmitted::class); Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(CreateAssetsFromFileUploads::class); Bus::assertNotDispatched(SendEmails::class); $form->submissions()->each->delete(); @@ -658,6 +1016,7 @@ public function it_resumes_a_partial_submission_on_a_later_page() $this->assertTrue($result->submission->isPartial()); Event::assertNotDispatched(FormSubmitted::class); Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(CreateAssetsFromFileUploads::class); Bus::assertNotDispatched(SendEmails::class); // Earlier-page values are preserved while the new page's values are merged in. @@ -704,6 +1063,7 @@ public function it_finalizes_the_partial_submission_on_the_final_page() // Finalizing fires the completion events once; it doesn't re-create the submission. Event::assertNotDispatched(SubmissionCreated::class); Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(CreateAssetsFromFileUploads::class, 1); Bus::assertDispatched(SendEmails::class, 1); $form->submissions()->each->delete(); @@ -730,6 +1090,7 @@ public function it_returns_to_the_first_page_when_finalizing_without_completing_ Event::assertNotDispatched(FormSubmitted::class); Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(CreateAssetsFromFileUploads::class); Bus::assertNotDispatched(SendEmails::class); $form->submissions()->each->delete(); diff --git a/tests/Forms/Uploaders/FormFileUploadTest.php b/tests/Forms/Uploaders/FormFileUploadTest.php new file mode 100644 index 00000000000..66a6c227342 --- /dev/null +++ b/tests/Forms/Uploaders/FormFileUploadTest.php @@ -0,0 +1,154 @@ + 'document', 'max_files' => 1], 'submission-123') + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + $this->assertEquals('submission-123/document/resume.pdf', $path); + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/document/resume.pdf'); + } + + #[Test] + public function it_writes_multiple_files_and_returns_an_array_of_paths() + { + Storage::fake('local'); + + $paths = FormFileUpload::field(['handle' => 'documents', 'max_files' => 3], 'submission-123') + ->upload([ + UploadedFile::fake()->create('one.pdf', 10), + UploadedFile::fake()->create('two.pdf', 10), + ]); + + $this->assertEquals([ + 'submission-123/documents/one.pdf', + 'submission-123/documents/two.pdf', + ], $paths); + + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/documents/one.pdf'); + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/documents/two.pdf'); + } + + #[Test] + public function it_only_takes_the_first_file_when_the_field_is_single_file() + { + Storage::fake('local'); + + $path = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], 'submission-123') + ->upload([ + UploadedFile::fake()->create('one.pdf', 10), + UploadedFile::fake()->create('two.pdf', 10), + ]); + + $this->assertEquals('submission-123/document/one.pdf', $path); + Storage::disk('local')->assertMissing('statamic/form-uploads/submission-123/document/two.pdf'); + } + + #[Test] + public function different_fields_on_the_same_submission_get_their_own_folder() + { + Storage::fake('local'); + + FormFileUpload::field(['handle' => 'avatar', 'max_files' => 1], 'submission-123') + ->upload([UploadedFile::fake()->image('avatar.jpg')]); + + FormFileUpload::field(['handle' => 'document', 'max_files' => 1], 'submission-123') + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/avatar/avatar.jpg'); + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/document/resume.pdf'); + } + + #[Test] + public function it_keeps_files_with_the_same_name_by_suffixing_duplicates() + { + Storage::fake('local'); + + $paths = FormFileUpload::field(['handle' => 'documents', 'max_files' => 3], 'submission-123') + ->upload([ + UploadedFile::fake()->create('resume.pdf', 10), + UploadedFile::fake()->create('resume.pdf', 10), + UploadedFile::fake()->create('resume.pdf', 10), + ]); + + $this->assertEquals([ + 'submission-123/documents/resume.pdf', + 'submission-123/documents/resume-1.pdf', + 'submission-123/documents/resume-2.pdf', + ], $paths); + + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/documents/resume.pdf'); + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/documents/resume-1.pdf'); + Storage::disk('local')->assertExists('statamic/form-uploads/submission-123/documents/resume-2.pdf'); + } + + #[Test] + public function it_writes_to_the_configured_disk() + { + config(['statamic.system.file_uploads_disk' => 'uploads']); + + $localDisk = Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + + FormFileUpload::field(['handle' => 'document', 'max_files' => 1], 'submission-123') + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + $uploadsDisk->assertExists('statamic/form-uploads/submission-123/document/resume.pdf'); + $localDisk->assertMissing('statamic/form-uploads/submission-123/document/resume.pdf'); + } + + #[Test] + public function it_writes_to_the_configured_path() + { + config(['statamic.forms.file_uploads_path' => 'temp-form-uploads']); + + $localDisk = Storage::fake('local'); + + $path = FormFileUpload::field(['handle' => 'document', 'max_files' => 1], 'submission-123') + ->upload([UploadedFile::fake()->create('resume.pdf', 10)]); + + $this->assertEquals('submission-123/document/resume.pdf', $path); + + $localDisk->assertExists('temp-form-uploads/submission-123/document/resume.pdf'); + $localDisk->assertMissing('statamic/form-uploads/submission-123/document/resume.pdf'); + } + + #[Test] + public function it_suffixes_duplicates_on_the_configured_disk_and_path() + { + config([ + 'statamic.system.file_uploads_disk' => 'uploads', + 'statamic.forms.file_uploads_path' => 'temp-form-uploads', + ]); + + Storage::fake('local'); + $uploadsDisk = Storage::fake('uploads'); + + $paths = FormFileUpload::field(['handle' => 'documents', 'max_files' => 2], 'submission-123') + ->upload([ + UploadedFile::fake()->create('resume.pdf', 10), + UploadedFile::fake()->create('resume.pdf', 10), + ]); + + $this->assertEquals([ + 'submission-123/documents/resume.pdf', + 'submission-123/documents/resume-1.pdf', + ], $paths); + + $uploadsDisk->assertExists('temp-form-uploads/submission-123/documents/resume.pdf'); + $uploadsDisk->assertExists('temp-form-uploads/submission-123/documents/resume-1.pdf'); + } +}