*/ private array $form_fields = array(); /** * Constructor. * * @param FormRepository $forms Repository to read and write forms through. * @param FieldRepository $fields Repository to read and write fields through. * @param DeliveryLog $log Store holding the webhook deliveries. * @param IntegrationRegistry $integrations Lookup for the available services. * @param FieldSanitizer $sanitizer Cleaner for the submitted field definitions. */ public function __construct( private FormRepository $forms, private FieldRepository $fields, private DeliveryLog $log, private IntegrationRegistry $integrations, private FieldSanitizer $sanitizer ) {} /** * Returns the browser title. */ public function page_title(): string { return 0 === $this->form_id() ? __( 'Add New Form', 'devplugins-custom-form-builder' ) : __( 'Edit Form', 'devplugins-custom-form-builder' ); } /** * Returns the menu label. */ public function menu_title(): string { return __( 'Add New', 'devplugins-custom-form-builder' ); } /** * Returns the required capability. */ public function capability(): string { return Capabilities::get( Capabilities::MANAGE_FORMS ); } /** * Saves the submission and loads the form being edited. */ public function load(): void { if ( ! current_user_can( $this->capability() ) ) { return; } if ( $this->is_save_request() ) { $this->save(); } $form_id = $this->form_id(); if ( 0 === $form_id ) { return; } $this->form = $this->forms->find( $form_id ); if ( $this->form instanceof Form ) { $this->form_fields = $this->fields->for_form( $form_id ); return; } // The form was deleted in another tab; send the user back to the list. wp_safe_redirect( add_query_arg( array( 'page' => FormsPage::SLUG, FormsPage::MESSAGE_ARG => 'missing', ), admin_url( 'admin.php' ) ) ); exit; } /** * Returns the view path. */ protected function view(): string { return 'admin/forms/edit'; } /** * Returns the data passed to the view. * * @return array Variables exposed to the template. */ protected function data(): array { $form = $this->form ?? new Form(); return array( 'page_title' => $this->page_title(), 'form' => $form, 'form_id' => $form->id(), 'fields' => $this->form_fields, 'field_types' => FieldTypes::all(), 'notifications' => $this->notification_settings( $form ), 'email_fields' => $this->email_fields(), 'webhooks' => $this->webhook_settings( $form ), 'webhook_log' => $form->id() > 0 ? $this->log->recent( $form->id(), 20 ) : array(), 'webhook_test' => array( 'action' => Webhooks::TEST_ACTION, 'nonce' => wp_create_nonce( Webhooks::TEST_NONCE ), ), 'integrations' => $this->integration_settings( $form ), 'field_choices' => $this->field_choices(), 'statuses' => Form::statuses(), 'nonce_action' => self::nonce_action( $form->id() ), 'submit_field' => self::SUBMIT_FIELD, 'action_url' => $this->url( 0 === $form->id() ? array() : array( 'form' => $form->id() ) ), 'list_url' => add_query_arg( array( 'page' => FormsPage::SLUG ), admin_url( 'admin.php' ) ), 'notice' => $this->notice(), ); } /** * Returns the notification settings shown in the editor. * * @param Form $form Form being edited. * * @return array{admin: array, auto_reply: array} The settings. */ private function notification_settings( Form $form ): array { $settings = $form->settings(); $stored = isset( $settings['notifications'] ) && is_array( $settings['notifications'] ) ? $settings['notifications'] : array(); return array( 'admin' => array_merge( array( 'enabled' => true, 'to' => (string) get_option( 'admin_email' ), 'cc' => '', 'bcc' => '', 'subject' => '', 'message' => '', 'from_name' => '', 'from_email' => '', 'attach_files' => true, ), isset( $stored['admin'] ) && is_array( $stored['admin'] ) ? $stored['admin'] : array() ), 'auto_reply' => array_merge( array( 'enabled' => false, 'to_field' => '', 'subject' => '', 'message' => '', 'from_name' => '', 'from_email' => '', 'reply_to' => (string) get_option( 'admin_email' ), ), isset( $stored['auto_reply'] ) && is_array( $stored['auto_reply'] ) ? $stored['auto_reply'] : array() ), ); } /** * Returns the email fields an auto reply can be addressed to. * * @return array Labels keyed by field key. */ private function email_fields(): array { $fields = array(); foreach ( $this->form_fields as $field ) { if ( FieldTypes::EMAIL !== $field->type() ) { continue; } $fields[ $field->field_key() ] = '' !== $field->label() ? $field->label() : $field->field_key(); } return $fields; } /** * Determines whether the request is a save submission. * * @return bool True when the builder form was submitted. */ private function is_save_request(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The nonce is verified in save() before any write. return Request::has( self::SUBMIT_FIELD, 'post' ); } /** * Validates and stores the submitted form. */ private function save(): void { $form_id = Request::int( 'form' ); /* * Scoped to the record, as the rest of the plugin's nonces are. A token * minted for form 5 used to be accepted against form 6, because the action * was the bare string `formpilot_save_form` — so a stale tab, or a token lifted * from one editor screen, wrote to whichever form the request named. */ SecurityManager::verify_admin_request( self::nonce_action( $form_id ), Capabilities::MANAGE_FORMS ); $data = $this->sanitized_input( $form_id ); if ( 0 === $form_id ) { $form_id = $this->forms->insert( $data ); if ( 0 === $form_id ) { $this->redirect( 0, 'error' ); } $this->redirect( $form_id, $this->save_fields( $form_id ) ? 'created' : 'fields_failed' ); } /* * The form has to still be there. `update()` answers true for an id that * matches nothing — `$wpdb->update()` returns 0 rows changed, not false — * so a save against a form deleted in another tab used to insert field rows * pointing at nothing and report "Form updated." */ if ( ! $this->forms->find( $form_id ) instanceof Form ) { $this->redirect( 0, 'missing' ); } $updated = $this->forms->update( $form_id, $data ); $stored = $this->save_fields( $form_id ); if ( ! $stored ) { $this->redirect( $form_id, 'fields_failed' ); } $this->redirect( $form_id, $updated ? 'updated' : 'error' ); } /** * Stores the submitted field set. * * Add, edit, delete and reorder all arrive as one payload, so the repository * reconciles the stored rows with what the builder sent. * * @param int $form_id Form the fields belong to. */ private function save_fields( int $form_id ): bool { if ( $form_id <= 0 ) { return false; } $raw = Request::raw( 'formpilot_fields', 'post' ); $fields = $this->sanitizer->sanitize_collection( $raw ); $stored = $this->fields->sync( $form_id, $fields ); /* * The answer is acted on. `sync()` is all-or-nothing now, and it answers * with an empty array when the transaction rolled back — a signal that was * being discarded here, so a save that stored nothing at all still redirected * with "Form updated." An empty submitted set legitimately produces an empty * result, which is why both are compared rather than just the result. */ return array() === $fields || array() !== $stored; } /** * Returns the sanitised submission. * * @param int $form_id Form being saved, 0 when creating. * * @return array Column data ready for the repository. */ private function sanitized_input( int $form_id ): array { $title = Request::text( 'formpilot_title' ); $slug = sanitize_title( Request::text( 'formpilot_slug' ) ); $status = Request::key( 'formpilot_status' ); if ( '' === $title ) { $title = __( '(no title)', 'devplugins-custom-form-builder' ); } if ( ! array_key_exists( $status, Form::statuses() ) ) { $status = Form::STATUS_DRAFT; } return array( 'title' => $title, 'slug' => $slug, 'status' => $status, 'settings' => $this->sanitized_settings( $form_id ), ); } /** * Returns the settings payload, merged over what the form already stores. * * Merging rather than replacing keeps settings this screen does not edit, * such as the submit label, intact. * * @param int $form_id Form being saved, 0 when creating. * * @return array The settings payload. */ private function sanitized_settings( int $form_id ): array { $existing = array(); if ( $form_id > 0 ) { $form = $this->forms->find( $form_id ); if ( $form instanceof Form ) { $existing = $form->settings(); } } $raw = Request::raw( 'formpilot_notifications', 'post' ); $raw = is_array( $raw ) ? $raw : array(); $admin = is_array( $raw['admin'] ?? null ) ? $raw['admin'] : array(); $reply = is_array( $raw['auto_reply'] ?? null ) ? $raw['auto_reply'] : array(); $existing['notifications'] = array( 'admin' => array( 'enabled' => ! empty( $admin['enabled'] ), 'to' => $this->clean_text( $admin['to'] ?? '' ), 'cc' => $this->clean_text( $admin['cc'] ?? '' ), 'bcc' => $this->clean_text( $admin['bcc'] ?? '' ), 'subject' => $this->clean_text( $admin['subject'] ?? '' ), /* * `Escape::rich_text()`, not `wp_kses_post()`. The stated policy for * admin-authored HTML is the plugin's own 14-tag allow-list; the core * function permits a far wider set, including `video`, `audio`, * `object`, `param` and `style` attributes. The REST route already * used the plugin's policy, so the two save paths also disagreed * about what a notification body may contain. */ 'message' => Escape::rich_text( (string) ( $admin['message'] ?? '' ) ), 'from_name' => $this->clean_text( $admin['from_name'] ?? '' ), 'from_email' => $this->clean_text( $admin['from_email'] ?? '' ), 'attach_files' => ! empty( $admin['attach_files'] ), ), 'auto_reply' => array( 'enabled' => ! empty( $reply['enabled'] ), 'to_field' => sanitize_key( (string) ( $reply['to_field'] ?? '' ) ), 'subject' => $this->clean_text( $reply['subject'] ?? '' ), // See the admin body above: one policy for admin-authored markup. 'message' => Escape::rich_text( (string) ( $reply['message'] ?? '' ) ), 'from_name' => $this->clean_text( $reply['from_name'] ?? '' ), 'from_email' => $this->clean_text( $reply['from_email'] ?? '' ), 'reply_to' => $this->clean_text( $reply['reply_to'] ?? '' ), ), ); /* * Only rewritten when the request actually carried the section. * * Both builders answer "nothing configured" when their input is missing — * `sanitized_webhooks()` returns an empty array, and * `sanitized_integrations()` walks every registered service and writes each * declared option as an empty string. So any save that did not include * those inputs, for any reason (a template that filtered the panel out, a * JavaScript failure, a partial POST), silently erased every webhook secret * and every API key on the form. Absent input now means "not edited". */ if ( Request::has( 'formpilot_webhooks', 'post' ) ) { $existing[ WebhookRegistry::SETTING ] = $this->sanitized_webhooks( $existing ); } if ( Request::has( 'formpilot_integrations', 'post' ) ) { $existing[ IntegrationRegistry::SETTING ] = $this->sanitized_integrations( $existing ); } return $existing; } /** * Returns the webhooks of a form, ready for the editor. * * The signing secret is replaced with a mask. It used to be printed into the * page so it could be corrected, on the reasoning that the screen already * requires `manage_forms` — but the value is only ever *set*, never read back * by a human, so rendering it buys nothing and puts a credential into the * HTML, the browser cache and any admin-side XSS. Typing a new one still * replaces it; leaving the mask alone keeps what is stored. * * @param Form $form Form being edited. * * @return array> Webhooks keyed by identifier. */ private function webhook_settings( Form $form ): array { $stored = $form->setting( WebhookRegistry::SETTING, array() ); $stored = is_array( $stored ) ? $stored : array(); $webhooks = array(); foreach ( $stored as $key => $data ) { $key = sanitize_key( (string) $key ); if ( '' === $key || ! is_array( $data ) ) { continue; } $webhooks[ $key ] = array( 'name' => (string) ( $data['name'] ?? '' ), 'url' => (string) ( $data['url'] ?? '' ), 'secret' => Redact::secret( $data['secret'] ?? '' ), 'enabled' => ! empty( $data['enabled'] ), 'headers' => WebhookHeaders::format( is_array( $data['headers'] ?? null ) ? array_map( 'strval', $data['headers'] ) : array() ), ); } return $webhooks; } /** * Reads the webhooks out of the request. * * @return array> Webhooks keyed by identifier. */ private function sanitized_webhooks( array $existing = array() ): array { $raw = Request::raw( 'formpilot_webhooks', 'post' ); $raw = is_array( $raw ) ? $raw : array(); $stored = is_array( $existing[ WebhookRegistry::SETTING ] ?? null ) ? $existing[ WebhookRegistry::SETTING ] : array(); $webhooks = array(); foreach ( $raw as $key => $data ) { if ( ! is_array( $data ) ) { continue; } /* * Screened here, not just escaped. `esc_url_raw()` decides whether a * string is a URL; it has no opinion about whether the server should be * asked to fetch it, and `127.0.0.1`, `[::1]` and the cloud metadata * address are all perfectly good URLs. */ $url = OutboundUrl::sanitize( (string) ( $data['url'] ?? '' ) ); // A row with no address is one the editor added and left blank, or one // they cleared to remove it. Either way there is nothing to store. if ( '' === $url ) { continue; } $key = sanitize_key( (string) $key ); $key = '' === $key ? 'hook-' . ( count( $webhooks ) + 1 ) : $key; // An untouched secret field posts back the mask the screen rendered. $secret = Redact::is_mask( $data['secret'] ?? '' ) ? (string) ( $stored[ $key ]['secret'] ?? '' ) : $this->clean_text( $data['secret'] ?? '' ); $webhooks[ $key ] = array( 'name' => $this->clean_text( $data['name'] ?? '' ), 'url' => $url, 'secret' => $secret, 'enabled' => ! empty( $data['enabled'] ), 'headers' => WebhookHeaders::parse( is_scalar( $data['headers'] ?? '' ) ? sanitize_textarea_field( (string) $data['headers'] ) : '' ), ); if ( count( $webhooks ) >= WebhookRegistry::LIMIT ) { break; } } return $webhooks; } /** * Describes every integration for the editor. * * @param Form $form Form being edited. * * @return array> One entry per service. */ private function integration_settings( Form $form ): array { $described = array(); foreach ( $this->integrations->all() as $id => $integration ) { $options = $integration->options(); $described[] = array( 'id' => $id, 'label' => $integration->label(), 'description' => $integration->description(), 'options' => $options, // Masked for the same reason as the webhook secret: a stored API key // is written, never read back, so printing it into the page is a // credential disclosure that buys the administrator nothing. 'config' => $this->mask_config( $this->integrations->config( $form, $id ), $options ), // Lets the template offer the one thing a service needs that its // options cannot describe, such as a script to copy. 'class' => $integration::class, ); } return $described; } /** * Returns the fields an integration can map a value from. * * @return array Labels keyed by field key. */ private function field_choices(): array { $choices = array(); foreach ( $this->form_fields as $field ) { if ( FieldTypes::STEP === $field->type() ) { continue; } $key = $field->field_key(); $choices[ $key ] = '' !== $field->label() ? $field->label() . ' (' . $key . ')' : $key; } return $choices; } /** * Reads the integration settings out of the request. * * Cleaned from each service's own option definitions, so a service is not * trusted to sanitise its own input and adding one cannot introduce a gap * here by forgetting to. * * @param array $existing Settings the form already stores. * * @return array> Configuration keyed by service. */ private function sanitized_integrations( array $existing = array() ): array { $raw = Request::raw( 'formpilot_integrations', 'post' ); $raw = is_array( $raw ) ? $raw : array(); $stored = is_array( $existing[ IntegrationRegistry::SETTING ] ?? null ) ? $existing[ IntegrationRegistry::SETTING ] : array(); $clean = array(); foreach ( $this->integrations->all() as $id => $integration ) { $submitted = is_array( $raw[ $id ] ?? null ) ? $raw[ $id ] : array(); $previous = is_array( $stored[ $id ] ?? null ) ? $stored[ $id ] : array(); /* * A service absent from the payload keeps what it had. Walking every * registered service and writing each option regardless meant enabling * one service cleared the credentials of the six that were not on screen. */ if ( array() === $submitted && array() !== $previous ) { $clean[ $id ] = $previous; continue; } $config = array( 'enabled' => ! empty( $submitted['enabled'] ) ); foreach ( $integration->options() as $option ) { $key = (string) ( $option['key'] ?? '' ); if ( '' === $key ) { continue; } $value = $submitted[ $key ] ?? null; /* * The screen renders a stored credential as a mask rather than as the * key itself, so an untouched field posts the mask back. Writing it * would replace the credential with a row of asterisks. */ if ( $this->is_secret_option( $option ) && Redact::is_mask( $value ) ) { $config[ $key ] = $previous[ $key ] ?? ''; continue; } $config[ $key ] = $this->clean_option( $option, $value ); } $clean[ $id ] = $config; } return $clean; } /** * Replaces an integration's stored credentials with a mask. * * @param array $config Stored configuration. * @param array> $options Option definitions. * * @return array The configuration, safe to render. */ private function mask_config( array $config, array $options ): array { foreach ( $options as $option ) { $key = (string) ( $option['key'] ?? '' ); if ( '' !== $key && $this->is_secret_option( $option ) && isset( $config[ $key ] ) ) { $config[ $key ] = Redact::secret( $config[ $key ] ); } } return $config; } /** * Determines whether an integration option holds a credential. * * @param array $option Option definition. */ private function is_secret_option( array $option ): bool { if ( ! empty( $option['secret'] ) ) { return true; } $key = strtolower( (string) ( $option['key'] ?? '' ) ); foreach ( array( 'secret', 'token', 'api_key', 'apikey', 'password' ) as $needle ) { if ( str_contains( $key, $needle ) ) { return true; } } return false; } /** * Cleans one integration option to its declared type. * * @param array $option Option definition. * @param mixed $value Raw value. * * @return mixed The cleaned value. */ private function clean_option( array $option, mixed $value ): mixed { $type = (string) ( $option['type'] ?? 'text' ); if ( 'checkbox' === $type ) { return ! empty( $value ); } $text = is_scalar( $value ) ? trim( (string) $value ) : ''; return match ( $type ) { // Screened, not merely escaped: this is an address the server will be // asked to post to. See OutboundUrl. 'url' => OutboundUrl::sanitize( $text ), 'textarea' => sanitize_textarea_field( is_scalar( $value ) ? (string) $value : '' ), 'field' => sanitize_key( $text ), 'select' => array_key_exists( $text, (array) ( $option['choices'] ?? array() ) ) ? $text : '', default => sanitize_text_field( $text ), }; } /** * Sanitises a single line setting. * * Addresses keep their merge tags here; they are resolved and validated when * the notification is built. * * @param mixed $value Raw value. */ private function clean_text( mixed $value ): string { return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : ''; } /** * Sends the browser back to the editor. * * @param int $form_id Form to open, 0 when the save failed. * @param string $message Message key describing the outcome. */ private function redirect( int $form_id, string $message ): void { $args = array( FormsPage::MESSAGE_ARG => $message ); if ( $form_id > 0 ) { $args['form'] = $form_id; } wp_safe_redirect( $this->url( $args ) ); exit; } /** * Builds the notice shown after a save. * * @return array{type: string, text: string}|null The notice, or null when there is nothing to report. */ private function notice(): ?array { $message = Request::key( FormsPage::MESSAGE_ARG ); if ( '' === $message ) { return null; } return match ( $message ) { 'created' => array( 'type' => 'success', 'text' => __( 'Form created.', 'devplugins-custom-form-builder' ), ), 'updated' => array( 'type' => 'success', 'text' => __( 'Form updated.', 'devplugins-custom-form-builder' ), ), 'missing' => array( 'type' => 'error', 'text' => __( 'That form no longer exists, so nothing was saved.', 'devplugins-custom-form-builder' ), ), 'fields_failed' => array( 'type' => 'error', 'text' => __( 'The fields could not be saved, so none of the changes to them were kept. The form itself is unchanged. Please try again, and check the log under FormPilot → Tools if it keeps happening.', 'devplugins-custom-form-builder' ), ), default => array( 'type' => 'error', 'text' => __( 'The form could not be saved.', 'devplugins-custom-form-builder' ), ), }; } /** * Returns the form being edited. * * @return int The form ID, or 0 when creating a new form. */ private function form_id(): int { return Request::int( 'form' ); } }