Registered add-ons keyed by slug. */ private static $registry = array(); /** * Register an add-on. Call on `plugins_loaded` or later. * * Supported callbacks, all optional: * - `sanitize_form( array $data, array $schema, array $raw ): array{0: array, 1: array}` * Returns clean data and a list of issues (Schema::issue() arrays or strings). * - `sanitize_field( array $data, array $field, int $position, array $raw_field ): array{0: array, 1: array}` * - `check( array $schema ): array` Cross-field issues, run after everything else. * - `portable( array $data, array $schema ): array` Data safe to include in a * form export. Without this callback the add-on's data is left out of exports. * - `portable_field( array $data, array $field, array $schema ): array` Explicit * approval for settings exported with one field. * * @param string $slug Short key, such as `pro`. * @param array $config Label and callbacks. * @return bool Whether the add-on was registered. */ public static function register( $slug, array $config ) { $slug = sanitize_key( $slug ); if ( '' === $slug || isset( self::$registry[ $slug ] ) ) { return false; } self::$registry[ $slug ] = array_merge( array( 'label' => $slug, 'sanitize_form' => null, 'sanitize_field' => null, 'check' => null, 'portable' => null, 'portable_field' => null, ), $config ); return true; } /** Whether an add-on with this slug is active. */ public static function registered( $slug ) { return isset( self::$registry[ sanitize_key( $slug ) ] ); } /** Remove an add-on registration. Used by tests. */ public static function unregister( $slug ) { unset( self::$registry[ sanitize_key( $slug ) ] ); } /** Fire an action; a failing listener cannot undo durable work. */ public static function notify( $hook, ...$args ) { if ( 0 !== strpos( $hook, 'accessible_forms_' ) ) { return; } // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Only hook names starting with accessible_forms_ reach this line (checked above). try { do_action( $hook, ...$args ); } catch ( \Throwable $error ) { self::failed( $hook ); return false; } // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound return true; } /** * Apply a filter; required checks reject on failure, presentation retains its value. * * @param string $hook Hook name starting with accessible_forms_. * @param mixed $value Value to filter. * @param mixed ...$args Extra arguments. * @return mixed */ public static function filter( $hook, $value, ...$args ) { if ( 0 !== strpos( $hook, 'accessible_forms_' ) ) { return $value; } // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Only hook names starting with accessible_forms_ reach this line (checked above). try { return apply_filters( $hook, $value, ...$args ); } catch ( \Throwable $error ) { self::failed( $hook ); if ( 'accessible_forms_mail_delivery' === $hook ) { // The provider may already have accepted mail. Never hand off again. return array( 'sent' => false, 'error' => 'The mail transport was interrupted.', 'uncertain' => true, 'reason' => 'interrupted' ); } if ( 'accessible_forms_mail_result' === $hook && is_array( $value ) && empty( $value['sent'] ) ) { $value['uncertain'] = true; $value['reason'] = 'interrupted'; return $value; } if ( 'accessible_forms_storage_ready' === $hook ) { return false; } if ( 'accessible_forms_spam_check' === $hook ) { return array( 'block' => true, 'reason' => 'extension_error' ); } if ( 'accessible_forms_entry_snapshot' === $hook ) { $value['extensions'] = array(); foreach ( (array) ( $value['fields'] ?? array() ) as $index => $field ) { $value['fields'][ $index ]['extensions'] = array(); } return $value; } if ( 'accessible_forms_user_can_form' === $hook ) { return false; } if ( 'accessible_forms_entries_access_sql' === $hook ) { return array( array( '1 = 0' ), array() ); } if ( 'accessible_forms_editor_save' === $hook ) { return new \WP_Error( 'extension', __( 'An add-on could not check your changes. Your saved version is kept. Try again or ask your administrator to check the add-on.', 'accessible-forms-by-accessible-org' ) ); } if ( 'accessible_forms_entry_stored' === $hook ) { return new \WP_Error( 'storage', __( 'An add-on could not save this submission. Your answers are kept. Please try again.', 'accessible-forms-by-accessible-org' ) ); } if ( 'accessible_forms_verification_ready' === $hook ) { return false; } if ( 'accessible_forms_validate_field' === $hook ) { return array( '', __( 'This answer could not be checked. Please try again.', 'accessible-forms-by-accessible-org' ), 'extension' ); } if ( 'accessible_forms_answer_errors' === $hook ) { return array( 'errors' => array( '_form' => __( 'These answers could not be checked. Please try again.', 'accessible-forms-by-accessible-org' ) ), 'codes' => array( '_form' => 'extension' ), ); } if ( 'accessible_forms_settings_before_save' === $hook ) { return new \WP_Error( 'extension', __( 'An add-on could not check these settings. Your saved settings are kept. Please try again.', 'accessible-forms-by-accessible-org' ) ); } if ( 'accessible_forms_inspect_submission' === $hook ) { return array( 'errors' => array( '_form' => __( 'An add-on could not check this submission. Your answers are kept. Please try again.', 'accessible-forms-by-accessible-org' ) ) ); } return $value; } // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound } /** * Sanitize a form's `extensions` data. * * @param mixed $raw Submitted `extensions` value. * @param array $schema Schema sanitized so far, including fields. * @param array $full Whole raw submission, for add-ons that read related input. * @return array{0: array, 1: array} Clean data and issues. */ public static function sanitize_form( $raw, array $schema, array $full ) { return self::sanitize_bag( $raw, 'sanitize_form', static function ( $callback, $data ) use ( $schema, $full ) { return $callback( $data, $schema, $full ); } ); } /** * Sanitize one field's `extensions` data. * * @param mixed $raw Submitted value. * @param array $field Field sanitized so far. * @param int $position Zero-based position in the submitted list. * @param array $raw_field Whole raw field. * @return array{0: array, 1: array} */ public static function sanitize_field( $raw, array $field, $position, array $raw_field ) { return self::sanitize_bag( $raw, 'sanitize_field', static function ( $callback, $data ) use ( $field, $position, $raw_field ) { return $callback( $data, $field, $position, $raw_field ); } ); } /** Cross-field issues from every active add-on. */ public static function check( array $schema ) { $issues = array(); foreach ( self::$registry as $slug => $config ) { if ( is_callable( $config['check'] ) ) { try { $issues = array_merge( $issues, (array) call_user_func( $config['check'], $schema ) ); } catch ( \Throwable $error ) { self::failed( 'check:' . $slug ); $issues[] = Schema::issue( __( 'An add-on could not validate this form. Your saved version is kept. Try again or ask your administrator to check the add-on.', 'accessible-forms-by-accessible-org' ) ); } } } return $issues; } /** Extension data that may leave the site in a form export. */ public static function portable( array $extensions, array $schema ) { $portable = array(); foreach ( $extensions as $slug => $data ) { $callback = self::$registry[ $slug ]['portable'] ?? null; if ( is_callable( $callback ) && is_array( $data ) ) { try { $clean = self::bounded( call_user_func( $callback, $data, $schema ) ); if ( $clean ) { $portable[ $slug ] = $clean; } } catch ( \Throwable $error ) { self::failed( 'portable:' . $slug ); } } } return $portable; } /** Field data needs its own explicit approval; form callbacks are not reused. */ public static function portable_field( array $extensions, array $field, array $schema ) { $portable = array(); foreach ( $extensions as $slug => $data ) { $callback = self::$registry[ $slug ]['portable_field'] ?? null; if ( is_callable( $callback ) && is_array( $data ) ) { try { $clean = self::bounded( call_user_func( $callback, $data, $field, $schema ) ); if ( $clean ) { $portable[ $slug ] = $clean; } } catch ( \Throwable $error ) { self::failed( 'portable_field:' . $slug ); } } } return $portable; } /** * Slugs whose data is stored but whose add-on is inactive. * * @param array $extensions A form's or field's `extensions`. * @return string[] */ public static function inactive( $extensions ) { if ( ! is_array( $extensions ) ) { return array(); } $extensions = self::decoded( $extensions ); return array_values( array_filter( array_keys( $extensions ), static function ( $slug ) use ( $extensions ) { return ! self::registered( $slug ) && ! empty( $extensions[ $slug ] ); } ) ); } /** * Hidden inputs that carry inactive add-ons' data through an editor save. * * @param array $extensions A form's or field's `extensions`. * @param string $base Input name prefix, such as `af_schema` or `af_schema[fields][2]`. * @return string Escaped HTML. */ public static function kept_inputs( $extensions, $base ) { $html = ''; $extensions = is_array( $extensions ) ? self::decoded( $extensions ) : array(); foreach ( self::inactive( $extensions ) as $slug ) { $html .= ''; } return $html; } /** * Add-on data as arrays. After a rejected save the editor shows the submitted * values again, where inactive add-ons' data is still the JSON text of its * hidden input; it must not be encoded a second time. * * @param array $extensions Data keyed by add-on slug. * @return array */ private static function decoded( array $extensions ) { foreach ( $extensions as $slug => $data ) { if ( is_string( $data ) ) { $decoded = json_decode( $data, true ); $extensions[ $slug ] = is_array( $decoded ) ? $decoded : array(); } } return $extensions; } /** Shared bag handling for forms and fields. */ private static function sanitize_bag( $raw, $callback_key, callable $invoke ) { $input = array(); foreach ( is_array( $raw ) ? $raw : array() as $key => $value ) { $key = sanitize_key( (string) $key ); if ( '' !== $key ) { $input[ $key ] = $value; } } $clean = array(); $issues = array(); $slugs = array_unique( array_merge( array_keys( self::$registry ), array_keys( $input ) ) ); foreach ( array_slice( $slugs, 0, self::MAX_SLUGS ) as $slug ) { $data = $input[ $slug ] ?? array(); // Inactive add-ons' data arrives from the editor as a JSON string. if ( is_string( $data ) ) { $data = json_decode( $data, true ); } $data = self::bounded( $data ); if ( null === $data ) { $issues[] = sprintf( /* translators: %s: add-on name. */ __( 'The settings for %s are too large or malformed, so they were not saved.', 'accessible-forms-by-accessible-org' ), self::$registry[ $slug ]['label'] ?? $slug ); continue; } $callback = self::$registry[ $slug ][ $callback_key ] ?? null; if ( is_callable( $callback ) ) { try { $result = $invoke( $callback, $data ); if ( ! is_array( $result ) || ! isset( $result[0], $result[1] ) || ! is_array( $result[0] ) || ! is_array( $result[1] ) ) { throw new \UnexpectedValueException( 'extension_settings_result' ); } $checked = self::bounded( $result[0] ); if ( null === $checked ) { throw new \UnexpectedValueException( 'extension_settings_bounds' ); } $data = $checked; $issues = array_merge( $issues, $result[1] ); } catch ( \Throwable $error ) { self::failed( $callback_key . ':' . $slug ); $clean[ $slug ] = $data; $issues[] = Schema::issue( __( 'An add-on could not check its settings. Your saved configuration is kept. Try again or ask your administrator to check the add-on.', 'accessible-forms-by-accessible-org' ) ); continue; } } elseif ( ! self::registered( $slug ) && array() === $data ) { continue; } if ( array() !== $data ) { $clean[ $slug ] = $data; } } return array( $clean, $issues ); } /** * Structural limits for data core does not understand: arrays with * key-safe keys, scalar leaves, bounded depth and size. * * @param mixed $data Candidate data. * @param int $depth Current depth. * @return array|null Clean array, or null when it breaks a limit. */ private static function bounded( $data, $depth = 1 ) { if ( null === $data ) { return array(); } if ( ! is_array( $data ) || $depth > self::MAX_DEPTH ) { return null; } $clean = array(); foreach ( $data as $key => $value ) { $key = is_int( $key ) ? $key : preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $key ); if ( '' === $key ) { continue; } if ( is_array( $value ) ) { $value = self::bounded( $value, $depth + 1 ); if ( null === $value ) { return null; } } elseif ( ! is_scalar( $value ) && null !== $value ) { return null; } $clean[ $key ] = $value; } if ( 1 === $depth && strlen( (string) wp_json_encode( $clean ) ) > self::MAX_BYTES ) { return null; } return $clean; } /** Remember the last failing hook for diagnostics. Never logs messages: they can contain answers. */ private static function failed( $hook ) { update_option( 'accessible_forms_extension_failure', array( 'hook' => sanitize_key( str_replace( ':', '_', $hook ) ), 'at' => gmdate( 'Y-m-d H:i:s' ), ), false ); } }