diff --git a/src/Exceptions/SilentFormFailureException.php b/src/Exceptions/SilentFormFailureException.php index 1f5dfcaeea3..39b19737cfa 100644 --- a/src/Exceptions/SilentFormFailureException.php +++ b/src/Exceptions/SilentFormFailureException.php @@ -2,7 +2,17 @@ namespace Statamic\Exceptions; +use Statamic\Contracts\Forms\Submission; + class SilentFormFailureException extends \Exception { - // + public function __construct(protected ?Submission $submission = null) + { + parent::__construct(); + } + + public function submission(): ?Submission + { + return $this->submission; + } } diff --git a/src/Forms/SubmitForm.php b/src/Forms/SubmitForm.php new file mode 100644 index 00000000000..9fe97390244 --- /dev/null +++ b/src/Forms/SubmitForm.php @@ -0,0 +1,132 @@ +blueprint()->fields(); + $fields = $fields->addValues($values); + $site = $site ?? Facades\Site::default(); + + $uploadedAssets = []; + $files = $this->normalizeFiles($form, $files); + + $this->withLocale($site->lang(), fn () => $this->validator($form, $data, $files)->validate()); + + $submission = $form->makeSubmission(); + + try { + throw_if(Arr::get($values, $form->honeypot()), new SilentFormFailureException($submission)); + + $uploadedAssets = $submission->uploadFiles($files); + + $values = array_merge($values, $uploadedAssets); + + $submission->data( + $fields->addValues($values)->process()->values() + ); + + // If any event listeners return false, we'll do a silent failure. + // If they want to add validation errors, they can throw an exception. + throw_if(FormSubmitted::dispatch($submission) === false, new SilentFormFailureException($submission)); + } catch (ValidationException|SilentFormFailureException $e) { + $this->removeUploadedAssets($uploadedAssets); + + throw $e; + } + + if ($form->store()) { + $submission->save(); + } else { + // When the submission is saved, this same created event will be dispatched. + // We'll also fire it here if submissions are not configured to be stored + // so that developers may continue to listen and modify it as needed. + SubmissionCreated::dispatch($submission); + } + + SendEmails::dispatch($submission, $site); + + return $submission; + } + + public function validator(Form $form, array $data, array $files = []): Validator + { + $values = array_merge($data, $files); + $fields = $form->blueprint()->fields()->addValues($values); + + return $fields + ->validator() + ->withRules($this->extraRules($fields)) + ->validator(); + } + + private function extraRules($fields): array + { + return $fields->all() + ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets') + ->mapWithKeys(function ($field) { + return [$field->handle().'.*' => ['file', new AllowedFile]]; + }) + ->all(); + } + + /** + * Normalize uploaded files to arrays. + * + * The assets fieldtype expects arrays, even for `max_files: 1`, + * but we don't want to force that on the front end. + */ + private function normalizeFiles(Form $form, array $files): array + { + $assetFields = $form->blueprint()->fields()->all() + ->filter(fn ($field) => in_array($field->fieldtype()->handle(), ['assets', 'files'])) + ->keys(); + + foreach ($assetFields as $handle) { + if (isset($files[$handle])) { + $files[$handle] = Arr::wrap($files[$handle]); + } + } + + return $files; + } + + /** + * Remove any uploaded assets. + * + * Triggered by a validation exception or silent failure. + */ + private function removeUploadedAssets(array $assets): void + { + collect($assets) + ->flatten() + ->each(function ($id) { + if ($asset = Asset::find($id)) { + $asset->delete(); + } + }); + } +} diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php index 6f60ddd54f7..71f4880c973 100644 --- a/src/Http/Controllers/FormController.php +++ b/src/Http/Controllers/FormController.php @@ -2,22 +2,20 @@ namespace Statamic\Http\Controllers; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\URL; use Illuminate\Support\MessageBag; use Illuminate\Validation\ValidationException; use Statamic\Contracts\Forms\Submission; -use Statamic\Events\FormSubmitted; -use Statamic\Events\SubmissionCreated; use Statamic\Exceptions\SilentFormFailureException; -use Statamic\Facades\Asset; use Statamic\Facades\Form; use Statamic\Facades\Site; use Statamic\Forms\Exceptions\FileContentTypeRequiredException; -use Statamic\Forms\SendEmails; -use Statamic\Http\Requests\FrontendFormRequest; +use Statamic\Forms\SubmitForm; use Statamic\Support\Arr; use Statamic\Support\Str; +use Symfony\Component\HttpFoundation\RedirectResponse; class FormController extends Controller { @@ -26,80 +24,41 @@ class FormController extends Controller * * @return mixed */ - public function submit(FrontendFormRequest $request, $form) + public function submit(Request $request, $form, SubmitForm $submitForm) { $site = Site::findByUrl(URL::previous()) ?? Site::default(); - $fields = $form->blueprint()->fields(); $this->validateContentType($request, $form); - $values = $request->all(); - $values = array_merge($values, $assets = $request->assets()); - $params = collect($request->all())->filter(function ($value, $key) { - return Str::startsWith($key, '_'); - })->all(); - - $fields = $fields->addValues($values); - - $submission = $form->makeSubmission(); + $params = collect($request->all()) + ->filter(fn ($value, string $key) => Str::startsWith($key, '_')) + ->all(); try { - throw_if(Arr::get($values, $form->honeypot()), new SilentFormFailureException); - - $uploadedAssets = $submission->uploadFiles($assets); - - $values = array_merge($values, $uploadedAssets); - - $submission->data( - $fields->addValues($values)->process()->values() + $submission = $submitForm->submit( + form: $form, + data: $request->all(), + files: $request->allFiles(), + site: $site, ); - // If any event listeners return false, we'll do a silent failure. - // If they want to add validation errors, they can throw an exception. - throw_if(FormSubmitted::dispatch($submission) === false, new SilentFormFailureException); + return $this->formSuccess($params, $submission); + } catch (SilentFormFailureException $e) { + return $this->formSuccess($params, $e->submission(), silentFailure: true); } catch (ValidationException $e) { - $this->removeUploadedAssets($uploadedAssets); - return $this->formFailure($params, $e->errors(), $form->handle()); - } catch (SilentFormFailureException $e) { - if (isset($uploadedAssets)) { - $this->removeUploadedAssets($uploadedAssets); - } - - return $this->formSuccess($params, $submission, true); - } - - if ($form->store()) { - $submission->save(); - } else { - // When the submission is saved, this same created event will be dispatched. - // We'll also fire it here if submissions are not configured to be stored - // so that developers may continue to listen and modify it as needed. - SubmissionCreated::dispatch($submission); } - - SendEmails::dispatch($submission, $site); - - return $this->formSuccess($params, $submission); } - private function validateContentType($request, $form) + private function validateContentType(Request $request, $form): void { $type = Str::before($request->headers->get('CONTENT_TYPE'), ';'); - if ($type !== 'multipart/form-data' && $form->hasFiles() && $request->assets()) { + if ($type !== 'multipart/form-data' && $form->hasFiles() && $request->allFiles()) { throw new FileContentTypeRequiredException; } } - /** - * The steps for a failed form submission. - * - * @param array $params - * @param array $errors - * @param string $form - * @return Response|RedirectResponse - */ - private function formFailure($params, $errors, $form) + private function formFailure(array $params, array $errors, string $form): Response|RedirectResponse { $request = request(); @@ -125,17 +84,7 @@ private function formFailure($params, $errors, $form) return $response->withInput()->withErrors($errors, 'form.'.$form); } - /** - * The steps for a successful form submission. - * - * Used for actual success and by honeypot. - * - * @param array $params - * @param Submission $submission - * @param bool $silentFailure - * @return Response - */ - private function formSuccess($params, $submission, $silentFailure = false) + private function formSuccess(array $params, Submission $submission, bool $silentFailure = false): Response|RedirectResponse { $redirect = $this->formSuccessRedirect($params, $submission); @@ -159,7 +108,7 @@ private function formSuccess($params, $submission, $silentFailure = false) return $response; } - private function formSuccessRedirect($params, $submission) + private function formSuccessRedirect(array $params, Submission $submission): ?string { if ($redirect = Form::getSubmissionRedirect($submission)) { return $redirect; @@ -173,20 +122,4 @@ private function formSuccessRedirect($params, $submission) return $redirect; } - - /** - * Remove any uploaded assets - * - * Triggered by a validation exception or silent failure - */ - private function removeUploadedAssets(array $assets) - { - collect($assets) - ->flatten() - ->each(function ($id) { - if ($asset = Asset::find($id)) { - $asset->delete(); - } - }); - } } diff --git a/src/Http/Requests/FrontendFormRequest.php b/src/Http/Requests/FrontendFormRequest.php deleted file mode 100644 index 795e042044c..00000000000 --- a/src/Http/Requests/FrontendFormRequest.php +++ /dev/null @@ -1,134 +0,0 @@ -assets; - } - - /** - * Determine if the user is authorized to make this request. - */ - public function authorize(): bool - { - return true; - } - - /** - * Optionally override the redirect url based on the presence of _error_redirect - */ - protected function getRedirectUrl() - { - $url = $this->redirector->getUrlGenerator(); - - if ($redirect = $this->input('_error_redirect')) { - return URL::isExternalToApplication($redirect) ? $url->previous() : $url->to($redirect); - } - - return $url->previous(); - } - - public function validator() - { - $fields = $this->getFormFields(); - - return $fields - ->validator() - ->withRules($this->extraRules($fields)) - ->validator(); - } - - protected function failedValidation(Validator $validator) - { - if ($this->ajax()) { - - $errors = $validator->errors(); - - $response = response([ - 'errors' => $errors->all(), - 'error' => collect($errors->messages())->map(function ($errors, $field) { - return $errors[0]; - })->all(), - ], 400); - - throw (new ValidationException($validator, $response)); - } - - return parent::failedValidation($validator); - } - - private function extraRules($fields) - { - return $fields->all() - ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets') - ->mapWithKeys(function ($field) { - return [$field->handle().'.*' => ['file', new AllowedFile]]; - }) - ->all(); - } - - private function getFormFields() - { - if ($this->cachedFields) { - return $this->cachedFields; - } - - $form = $this->route()->parameter('form'); - - $this->errorBag = 'form.'.$form->handle(); - - $fields = $form->blueprint()->fields(); - - $this->assets = $this->normalizeAssetsValues($fields); - - $values = array_merge($this->all(), $this->assets); - - return $this->cachedFields = $fields->addValues($values); - } - - private function normalizeAssetsValues($fields) - { - // The assets fieldtype is expecting an array, even for `max_files: 1`, but we don't want to force that on the front end. - return $fields->all() - ->filter(fn ($field) => in_array($field->fieldtype()->handle(), ['assets', 'files']) && $this->hasFile($field->handle())) - ->map(fn ($field) => Arr::wrap($this->file($field->handle()))) - ->all(); - } - - public function validateResolved() - { - // If this was submitted from a front-end form, we want to use the appropriate language - // for the translation messages. If there's no previous url, it was likely submitted - // directly in a headless format. In that case, we'll just use the default lang. - $site = ($previousUrl = $this->previousUrl()) ? Site::findByUrl($previousUrl) : null; - - return $this->withLocale($site?->lang(), fn () => parent::validateResolved()); - } - - private function previousUrl() - { - return ($referrer = request()->header('referer')) - ? url()->to($referrer) - : session()->previousUrl(); - } -} diff --git a/tests/Forms/SubmitFormTest.php b/tests/Forms/SubmitFormTest.php new file mode 100644 index 00000000000..ed04ed962d4 --- /dev/null +++ b/tests/Forms/SubmitFormTest.php @@ -0,0 +1,320 @@ +form = tap(Form::make('contact')->honeypot('winnie'), function ($form) { + $form->save(); + }); + + $this->form->blueprint()->ensureField('name', ['type' => 'text'])->save(); + $this->form->blueprint()->ensureField('email', ['type' => 'text', 'validate' => 'required|email'])->save(); + $this->form->blueprint()->ensureField('message', ['type' => 'textarea'])->save(); + } + + public function tearDown(): void + { + $this->form->submissions()->each->delete(); + + parent::tearDown(); + } + + #[Test] + public function it_submits_a_form_successfully() + { + Bus::fake(); + + $submission = app(SubmitForm::class)->submit( + form: $this->form, + data: ['name' => 'Test User', 'email' => 'test@example.com', 'message' => 'Hello'], + ); + + $this->assertNotNull($submission); + $this->assertEquals('Test User', $submission->get('name')); + $this->assertEquals('test@example.com', $submission->get('email')); + $this->assertEquals('Hello', $submission->get('message')); + } + + #[Test] + public function it_saves_submission_when_store_is_enabled() + { + Bus::fake(); + + $this->assertEmpty($this->form->submissions()); + + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com'], + ); + + $this->assertCount(1, $this->form->submissions()); + } + + #[Test] + public function it_dispatches_submission_created_event_when_store_is_disabled() + { + Bus::fake(); + Event::fake([SubmissionCreated::class]); + + $this->form->store(false); + $this->form->save(); + + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com'], + ); + + Event::assertDispatched(SubmissionCreated::class); + $this->assertEmpty($this->form->submissions()); + } + + #[Test] + public function it_throws_silent_failure_exception_when_honeypot_is_filled() + { + $this->expectException(SilentFormFailureException::class); + + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com', 'winnie' => 'the pooh'], + ); + } + + #[Test] + public function it_includes_submission_in_silent_failure_exception() + { + try { + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com', 'winnie' => 'the pooh'], + ); + + $this->fail('Expected SilentFormFailureException was not thrown'); + } catch (SilentFormFailureException $e) { + $this->assertNotNull($e->submission()); + $this->assertEquals($this->form, $e->submission()->form()); + } + } + + #[Test] + public function it_dispatches_form_submitted_event() + { + Bus::fake(); + Event::fake([FormSubmitted::class]); + + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com'], + ); + + Event::assertDispatched(FormSubmitted::class, function ($event) { + return $event->submission->get('email') === 'test@example.com'; + }); + } + + #[Test] + public function it_throws_silent_failure_exception_when_event_listener_returns_false() + { + Event::listen(FormSubmitted::class, function () { + return false; + }); + + try { + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com'], + ); + + $this->fail('Expected SilentFormFailureException was not thrown'); + } catch (SilentFormFailureException $e) { + $this->assertNotNull($e->submission()); + } + } + + #[Test] + public function it_throws_validation_exception_from_event_listener() + { + Event::listen(FormSubmitted::class, function () { + throw ValidationException::withMessages(['custom' => 'Custom validation error']); + }); + + $this->expectException(ValidationException::class); + + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com'], + ); + } + + #[Test] + public function it_dispatches_send_emails() + { + Bus::fake(); + + app(SubmitForm::class)->submit( + form: $this->form, + data: ['email' => 'test@example.com'], + site: Site::default(), + ); + + Bus::assertDispatched(SendEmails::class); + } + + #[Test] + public function it_creates_a_validator() + { + $validator = app(SubmitForm::class)->validator( + form: $this->form, + data: ['name' => 'Test'], + ); + + $this->assertFalse($validator->passes()); + $this->assertArrayHasKey('email', $validator->errors()->toArray()); + } + + #[Test] + public function validator_passes_with_valid_data() + { + $validator = app(SubmitForm::class)->validator( + form: $this->form, + data: ['email' => 'test@example.com'], + ); + + $this->assertTrue($validator->passes()); + } + + #[Test] + public function it_throws_validation_exception_when_validation_fails() + { + $this->expectException(ValidationException::class); + + app(SubmitForm::class)->submit( + form: $this->form, + data: ['name' => 'Test'], // missing required email + ); + } + + #[Test] + public function it_uploads_files() + { + Bus::fake(); + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads'), fn ($f) => $f->save()); + $form->blueprint()->ensureField('email', ['type' => 'text', 'validate' => 'required|email'])->save(); + $form->blueprint()->ensureField('avatar', ['type' => 'assets', 'container' => 'avatars'])->save(); + + $submission = app(SubmitForm::class)->submit( + form: $form, + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + + Storage::disk('avatars')->assertExists('avatar.jpg'); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_removes_uploaded_assets_on_silent_failure() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads')->honeypot('winnie'), fn ($f) => $f->save()); + $form->blueprint()->ensureField('email', ['type' => 'text'])->save(); + $form->blueprint()->ensureField('avatar', ['type' => 'assets', 'container' => 'avatars'])->save(); + + try { + app(SubmitForm::class)->submit( + form: $form, + data: ['email' => 'test@example.com', 'winnie' => 'the pooh'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + } catch (SilentFormFailureException $e) { + // Expected + } + + Storage::disk('avatars')->assertMissing('avatar.jpg'); + } + + #[Test] + public function it_removes_uploaded_assets_when_event_listener_returns_false() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads'), fn ($f) => $f->save()); + $form->blueprint()->ensureField('email', ['type' => 'text'])->save(); + $form->blueprint()->ensureField('avatar', ['type' => 'assets', 'container' => 'avatars'])->save(); + + Event::listen(FormSubmitted::class, function () { + return false; + }); + + try { + app(SubmitForm::class)->submit( + form: $form, + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + } catch (SilentFormFailureException $e) { + // Expected + } + + Storage::disk('avatars')->assertMissing('avatar.jpg'); + } + + #[Test] + public function it_removes_uploaded_assets_on_validation_exception() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = tap(Form::make('uploads'), fn ($f) => $f->save()); + $form->blueprint()->ensureField('email', ['type' => 'text'])->save(); + $form->blueprint()->ensureField('avatar', ['type' => 'assets', 'container' => 'avatars'])->save(); + + Event::listen(FormSubmitted::class, function () { + throw ValidationException::withMessages(['custom' => 'Error']); + }); + + try { + app(SubmitForm::class)->submit( + form: $form, + data: ['email' => 'test@example.com'], + files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]], + ); + } catch (ValidationException $e) { + // Expected + } + + Storage::disk('avatars')->assertMissing('avatar.jpg'); + } +} diff --git a/tests/Tags/Form/FormCreateTest.php b/tests/Tags/Form/FormCreateTest.php index 113fd0c90b8..cd356327a77 100644 --- a/tests/Tags/Form/FormCreateTest.php +++ b/tests/Tags/Form/FormCreateTest.php @@ -1197,78 +1197,6 @@ public function it_uploads_assets() Storage::disk('avatars')->assertExists('avatar.jpg'); } - #[Test] - public function it_removes_any_uploaded_assets_when_a_submission_silently_fails() - { - Storage::fake('avatars'); - AssetContainer::make('avatars')->disk('avatars')->save(); - - Event::listen(function (\Statamic\Events\FormSubmitted $event) { - return false; - }); - - $this->createForm([ - 'tabs' => [ - 'main' => [ - 'sections' => [ - [ - 'display' => 'One', - 'instructions' => 'One Instructions', - 'fields' => [ - ['handle' => 'alpha', 'field' => ['type' => 'text']], - ['handle' => 'bravo', 'field' => ['type' => 'assets', 'container' => 'avatars']], - ], - ], - ], - ], - ], - ], 'survey'); - - $this - ->post('/!/forms/survey', [ - 'alpha' => 'test', - 'bravo' => UploadedFile::fake()->image('avatar.jpg'), - ]); - - Storage::disk('avatars')->assertMissing('avatar.jpg'); - } - - #[Test] - public function it_removes_any_uploaded_assets_when_a_listener_throws_a_validation_exception() - { - Storage::fake('avatars'); - AssetContainer::make('avatars')->disk('avatars')->save(); - - Event::listen(function (\Statamic\Events\FormSubmitted $event) { - throw ValidationException::withMessages(['custom' => 'This is a custom message']); - }); - - $this->createForm([ - 'tabs' => [ - 'main' => [ - 'sections' => [ - [ - 'display' => 'One', - 'instructions' => 'One Instructions', - 'fields' => [ - ['handle' => 'alpha', 'field' => ['type' => 'text']], - ['handle' => 'bravo', 'field' => ['type' => 'assets', 'container' => 'avatars']], - ], - ], - ], - ], - ], - ], 'survey'); - - $this - ->post('/!/forms/survey', [ - 'alpha' => 'test', - 'bravo' => UploadedFile::fake()->image('avatar.jpg'), - ]); - - Storage::disk('avatars')->assertMissing('avatar.jpg'); - } - #[Test] public function it_renders_exceptions_thrown_during_json_requests_as_standard_laravel_errors() {