Skip to content

Commit

Permalink
[4.x] Support Laravel Precognition on front end forms (statamic#8886)
Browse files Browse the repository at this point in the history
Co-authored-by: Jason Varga <[email protected]>
  • Loading branch information
ryanmitchell and jasonvarga authored Nov 30, 2023
1 parent 2113059 commit 61b7ee5
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 74 deletions.
3 changes: 2 additions & 1 deletion routes/web.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
use Illuminate\Support\Facades\Route;
use Statamic\Auth\Protect\Protectors\Password\Controller as PasswordProtectController;
use Statamic\Facades\OAuth;
Expand All @@ -17,7 +18,7 @@

Route::name('statamic.')->group(function () {
Route::group(['prefix' => config('statamic.routes.action')], function () {
Route::post('forms/{form}', [FormController::class, 'submit'])->name('forms.submit');
Route::post('forms/{form}', [FormController::class, 'submit'])->middleware([HandlePrecognitiveRequests::class])->name('forms.submit');

Route::get('protect/password', [PasswordProtectController::class, 'show'])->name('protect.password.show');
Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store');
Expand Down
27 changes: 24 additions & 3 deletions src/Fields/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Statamic\Fields;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator as LaravelValidator;
use Statamic\Support\Arr;
use Statamic\Support\Str;
Expand Down Expand Up @@ -49,13 +50,15 @@ public function withContext($context)

public function rules()
{
return $this
$rules = $this
->merge($this->fieldRules(), $this->extraRules)
->map(function ($rules) {
return collect($rules)->map(function ($rule) {
return $this->parse($rule);
})->all();
})->all();

return $this->filterPrecognitiveRules($rules);
}

private function fieldRules()
Expand Down Expand Up @@ -93,16 +96,21 @@ public function withReplacements($replacements)
return $this;
}

public function validate()
public function validator()
{
return LaravelValidator::validate(
return LaravelValidator::make(
$this->fields->preProcessValidatables()->values()->all(),
$this->rules(),
$this->customMessages,
$this->attributes()
);
}

public function validate()
{
return $this->validator()->validate();
}

public function attributes()
{
return $this->fields->preProcessValidatables()->all()->reduce(function ($carry, $field) {
Expand Down Expand Up @@ -139,4 +147,17 @@ public static function explodeRules($rules)

return $rules;
}

public function filterPrecognitiveRules($rules)
{
$request = request();

if (! $request->headers->has('Precognition-Validate-Only')) {
return $rules;
}

return Collection::make($rules)
->only(explode(',', $request->header('Precognition-Validate-Only')))
->all();
}
}
72 changes: 3 additions & 69 deletions src/Http/Controllers/FormController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,8 @@

namespace Statamic\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\MessageBag;
use Illuminate\Support\Traits\Localizable;
use Illuminate\Validation\ValidationException;
use Statamic\Contracts\Forms\Submission;
use Statamic\Events\FormSubmitted;
use Statamic\Events\SubmissionCreated;
Expand All @@ -17,25 +12,23 @@
use Statamic\Facades\Site;
use Statamic\Forms\Exceptions\FileContentTypeRequiredException;
use Statamic\Forms\SendEmails;
use Statamic\Http\Requests\FrontendFormRequest;
use Statamic\Support\Arr;
use Statamic\Support\Str;
use Statamic\Validation\AllowedFile;

class FormController extends Controller
{
use Localizable;

/**
* Handle a form submission request.
*
* @return mixed
*/
public function submit(Request $request, $form)
public function submit(FrontendFormRequest $request, $form)
{
$site = Site::findByUrl(URL::previous()) ?? Site::default();
$fields = $form->blueprint()->fields();
$this->validateContentType($request, $form);
$values = array_merge($request->all(), $assets = $this->normalizeAssetsValues($fields, $request));
$values = array_merge($request->all(), $assets = $request->assets());
$params = collect($request->all())->filter(function ($value, $key) {
return Str::startsWith($key, '_');
})->all();
Expand All @@ -45,10 +38,6 @@ public function submit(Request $request, $form)
$submission = $form->makeSubmission();

try {
$this->withLocale($site->lang(), function () use ($fields) {
$fields->validate($this->extraRules($fields));
});

throw_if(Arr::get($values, $form->honeypot()), new SilentFormFailureException);

$values = array_merge($values, $submission->uploadFiles($assets));
Expand All @@ -60,8 +49,6 @@ public function submit(Request $request, $form)
// 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);
} catch (ValidationException $e) {
return $this->formFailure($params, $e->errors(), $form->handle());
} catch (SilentFormFailureException $e) {
return $this->formSuccess($params, $submission, true);
}
Expand Down Expand Up @@ -131,57 +118,4 @@ private function formSuccessRedirect($params, $submission)

return $redirect;
}

/**
* The steps for a failed form submission.
*
* @param array $params
* @param array $submission
* @param string $form
* @return Response|RedirectResponse
*/
private function formFailure($params, $errors, $form)
{
if (request()->ajax()) {
return response([
'errors' => (new MessageBag($errors))->all(),
'error' => collect($errors)->map(function ($errors, $field) {
return $errors[0];
})->all(),
], 400);
}

$redirect = Arr::get($params, '_error_redirect');

$response = $redirect ? redirect($redirect) : back();

return $response->withInput()->withErrors($errors, 'form.'.$form);
}

protected function normalizeAssetsValues($fields, $request)
{
// 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(function ($field) {
return $field->fieldtype()->handle() === 'assets' && request()->hasFile($field->handle());
})
->map(function ($field) use ($request) {
return Arr::wrap($request->file($field->handle()));
})
->all();
}

protected function extraRules($fields)
{
$assetFieldRules = $fields->all()
->filter(function ($field) {
return $field->fieldtype()->handle() === 'assets';
})
->mapWithKeys(function ($field) {
return [$field->handle().'.*' => ['file', new AllowedFile]];
})
->all();

return $assetFieldRules;
}
}
123 changes: 123 additions & 0 deletions src/Http/Requests/FrontendFormRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace Statamic\Http\Requests;

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Traits\Localizable;
use Illuminate\Validation\ValidationException;
use Statamic\Facades\Site;
use Statamic\Support\Arr;
use Statamic\Validation\AllowedFile;

class FrontendFormRequest extends FormRequest
{
use Localizable;

private $assets = [];
private $cachedFields;

/**
* Get any assets in the request
*/
public function assets(): array
{
return $this->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->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) => $field->fieldtype()->handle() === 'assets' && $this->hasFile($field->handle()))
->map(fn ($field) => Arr::wrap($this->file($field->handle())))
->all();
}

public function validateResolved()
{
$site = Site::findByUrl(URL::previous()) ?? Site::default();

return $this->withLocale($site->lang(), fn () => parent::validateResolved());
}
}
1 change: 0 additions & 1 deletion tests/Tags/Form/FormCreateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,6 @@ public function it_will_use_redirect_query_param_off_url()
/** @test */
public function it_can_render_an_inline_error_when_multiple_rules_fail()
{
$this->withoutExceptionHandling();
$this->assertEmpty(Form::find('contact')->submissions());

$this
Expand Down

0 comments on commit 61b7ee5

Please sign in to comment.