'handle_validate_step', self::SAVE_ACTION => 'handle_save_progress', self::LOAD_ACTION => 'handle_load_progress', ); // Guests fill in forms too, so every action is registered for both sides. foreach ( $callbacks as $action => $method ) { add_action( 'wp_ajax_' . $action, array( $this, $method ) ); add_action( 'wp_ajax_nopriv_' . $action, array( $this, $method ) ); } } /** * Validates the step the visitor is trying to leave. */ public function handle_validate_step(): void { $form = $this->request_form(); $fields = $this->fields->for_form( $form->id() ); $steps = $this->resolver->resolve( $fields ); $index = Request::int( 'formpilot_step' ); if ( ! isset( $steps[ $index ] ) ) { wp_send_json_error( array( 'code' => 'formpilot_unknown_step', 'message' => __( 'This form has changed. Please reload the page.', 'devplugins-custom-form-builder' ), ), 400 ); } $fields = $this->fields->for_form( $form->id() ); $values = $this->submitted_values(); $result = $this->validator->validate_step( $fields, $values, $steps[ $index ]->field_keys() ); if ( ! $result->is_valid() ) { wp_send_json_error( array( 'code' => 'formpilot_step_invalid', 'message' => __( 'Please check the highlighted fields before continuing.', 'devplugins-custom-form-builder' ), 'errors' => $result->errors(), ), 422 ); } $payload = array( 'step' => $index, 'total' => count( $steps ), 'token' => '', ); /* * Moving on is a natural place to keep the answers: the visitor who closes * the tab on step three should not lose steps one and two. It is a side * effect of a successful step, never a reason to refuse one, so a failure * to save is left out of the response rather than reported as an error. */ if ( $this->progress->is_enabled( $form ) ) { $token = $this->progress->save( $form->id(), $index + 1, $this->validator->sanitize( $fields, $values ), Request::text( 'formpilot_token' ) ); if ( ! $token instanceof WP_Error ) { $payload['token'] = $token; } } wp_send_json_success( $payload ); } /** * Stores the answers entered so far and hands back a resume link. * * The values are not validated: a form saved half way through is expected to * be incomplete. They are sanitised, because they are being stored. */ public function handle_save_progress(): void { $form = $this->request_form(); if ( ! $this->progress->is_enabled( $form ) ) { wp_send_json_error( array( 'code' => 'formpilot_progress_disabled', 'message' => __( 'This form cannot be saved for later.', 'devplugins-custom-form-builder' ), ), // 409, not 403: the feature is switched off, which is nothing to do // with the caller's credentials. 403 here read as a nonce failure. 409 ); } $fields = $this->fields->for_form( $form->id() ); $token = $this->progress->save( $form->id(), Request::int( 'formpilot_step' ), $this->validator->sanitize( $fields, $this->submitted_values() ), Request::text( 'formpilot_token' ) ); if ( $token instanceof WP_Error ) { wp_send_json_error( array( 'code' => $token->get_error_code(), 'message' => $token->get_error_message(), ), 400 ); } wp_send_json_success( array( 'token' => $token, 'url' => $this->progress->resume_url( $token ), 'expires' => $this->expiry_text(), ) ); } /** * Returns the answers saved under a token. */ public function handle_load_progress(): void { $form = $this->request_form(); /* * Checked here as well as on the save path. Turning `save_progress` off used * to stop new drafts being created while leaving every token already issued * readable until it expired — so an administrator switching the feature off * because of what it stores did not actually stop it being handed out. */ if ( ! $this->progress->is_enabled( $form ) ) { wp_send_json_error( array( 'code' => 'formpilot_progress_missing', 'message' => __( 'This saved form has expired or does not exist.', 'devplugins-custom-form-builder' ), ), 404 ); } $token = Request::text( 'formpilot_token' ); $record = $this->progress->load( $token ); /* * A token for another form is refused rather than ignored: answering with * an empty record would let a caller walk tokens and learn which ones * exist. The message is the same either way. */ if ( null === $record || $record['form_id'] !== $form->id() ) { wp_send_json_error( array( 'code' => 'formpilot_progress_missing', 'message' => __( 'This saved form has expired or does not exist.', 'devplugins-custom-form-builder' ), ), 404 ); } /* * The browser continues under a token of its own, not the one in the link. * * A resume link is a bearer credential that anybody can hand to anybody. * Writing on to the token it carried meant someone who started a draft and * sent the link to another visitor could poll that token and read every * answer the visitor typed afterwards. The link still restores what it was * saved with; what happens next goes somewhere only this browser knows. * When no new token can be issued (the creation throttle), the page starts * without one and the next save mints a fresh token instead. */ $fresh = $this->progress->save( $form->id(), (int) $record['step'], (array) $record['values'] ); wp_send_json_success( array( 'step' => $record['step'], 'values' => $record['values'], 'token' => is_string( $fresh ) ? $fresh : '', ) ); } /** * Verifies the request and returns the form it targets. * * Every endpoint here starts the same way, and none of them continues without * a valid nonce, so the checks answer the request themselves rather than * returning a value the caller could forget to look at. * * @return Form The form, when the request is allowed to reach it. */ private function request_form(): Form { if ( ! SecurityManager::verify_public_request( FormRenderer::NONCE_ACTION, FormRenderer::NONCE_FIELD ) ) { wp_send_json_error( array( 'code' => 'invalid_nonce', 'message' => __( 'This form has expired. Please reload the page and try again.', 'devplugins-custom-form-builder' ), ), 403 ); } $form_id = Request::int( 'formpilot_form_id' ); $form = $form_id > 0 ? $this->forms->find( $form_id ) : null; if ( ! $form instanceof Form || ! $form->is_published() ) { wp_send_json_error( array( 'code' => 'formpilot_unknown_form', 'message' => __( 'This form is no longer available.', 'devplugins-custom-form-builder' ), ), 404 ); } return $form; } /** * Returns the unslashed values submitted for the fields. * * @return array Raw values keyed by field key. */ private function submitted_values(): array { $raw = Request::raw( FieldView::NAME_PREFIX, 'post' ); return is_array( $raw ) ? $raw : array(); } /** * Returns the date the saved progress stops working, in site time. */ private function expiry_text(): string { return wp_date( (string) get_option( 'date_format', 'Y-m-d' ), time() + $this->progress->lifetime() ); } }