__( 'Text', 'customfields-com' ), 'textarea' => __( 'Text area', 'customfields-com' ), 'number' => __( 'Number', 'customfields-com' ), 'email' => __( 'Email', 'customfields-com' ), 'url' => __( 'URL', 'customfields-com' ), 'select' => __( 'Select', 'customfields-com' ), 'checkbox' => __( 'Checkbox (on/off)', 'customfields-com' ), 'date' => __( 'Date', 'customfields-com' ), 'color' => __( 'Color', 'customfields-com' ), 'image' => __( 'Image', 'customfields-com' ), 'file' => __( 'File', 'customfields-com' ), 'wysiwyg' => __( 'Rich text', 'customfields-com' ), 'radio' => __( 'Radio buttons', 'customfields-com' ), 'multiselect' => __( 'Multiple select', 'customfields-com' ), 'checkboxes' => __( 'Checkbox choices', 'customfields-com' ), 'time' => __( 'Time', 'customfields-com' ), 'datetime' => __( 'Date and time (site local)', 'customfields-com' ), 'post' => __( 'Related post', 'customfields-com' ), 'user' => __( 'Related user', 'customfields-com' ), 'taxonomy' => __( 'Related term', 'customfields-com' ), ) ); } /** Validate identifiers without disclosing private objects to unauthorized editors. */ private static function reference( $value, $field ) { if ( ! preg_match( '/^[1-9][0-9]*$/D', (string) $value ) || strlen( (string) $value ) > strlen( (string) PHP_INT_MAX ) || (float) $value > PHP_INT_MAX ) { return new \WP_Error( 'invalid_reference', __( 'Choose a valid existing item.', 'customfields-com' ) ); } $id = (int) $value; $type = $field['type']; $reading = ! empty( $field['_checking_storage'] ); $valid = false; if ( in_array( $type, array( 'image', 'file', 'post' ), true ) ) { $post = get_post( $id ); $valid = $post && ! in_array( $post->post_status, array( 'trash', 'auto-draft' ), true ); if ( in_array( $type, array( 'image', 'file' ), true ) ) { $valid = $valid && 'attachment' === $post->post_type && ( 'file' === $type || wp_attachment_is_image( $id ) ); } else { $valid = $valid && in_array( $post->post_type, $field['post_types'] ?? array( 'post' ), true ); } $valid = $valid && ( $reading || current_user_can( 'read_post', $id ) ); } elseif ( 'user' === $type ) { $valid = (bool) get_userdata( $id ) && ( $reading || current_user_can( 'list_users' ) || get_current_user_id() === $id ); } elseif ( 'taxonomy' === $type ) { $taxonomy = get_taxonomy( $field['taxonomy'] ?? '' ); $term = $taxonomy ? get_term( $id, $taxonomy->name ) : null; $valid = $term && ! is_wp_error( $term ) && ( $reading || current_user_can( $taxonomy->cap->assign_terms ) ); } return $valid ? $id : new \WP_Error( 'invalid_reference', __( 'The selected item is unavailable or you do not have permission to select it.', 'customfields-com' ) ); } /** Returns a safe native value or WP_Error. Arrays are accepted only by explicit collection types. */ public static function sanitize( $value, $field, $context = array() ) { $raw = $value; $type = $field['type'] ?? ''; if ( ! in_array( $type, self::CORE_TYPES, true ) ) { $clean = new \WP_Error( 'unsupported_type', __( 'This field requires an active extension.', 'customfields-com' ) ); } elseif ( in_array( $type, array( 'multiselect', 'checkboxes' ), true ) ) { if ( '' === $value || null === $value ) { $value = array(); } // The empty hidden control makes an explicitly cleared collection distinguishable from a stale form. if ( is_array( $value ) && isset( $value['__present'] ) ) { unset( $value['__present'] ); } $clean = array(); if ( ! is_array( $value ) || count( $value ) > 200 ) { $clean = new \WP_Error( 'invalid_choices', __( 'Expected a list of configured choices.', 'customfields-com' ) ); } else { foreach ( $value as $key => $choice ) { if ( ! ctype_digit( (string) $key ) || ! is_scalar( $choice ) || ! array_key_exists( (string) $choice, $field['choices'] ?? array() ) ) { $clean = new \WP_Error( 'invalid_choice', __( 'Choose only configured choices.', 'customfields-com' ) ); break; } $clean[] = (string) $choice; } if ( ! is_wp_error( $clean ) ) { $clean = array_values( array_unique( $clean ) ); } } } elseif ( ! is_scalar( $value ) && null !== $value ) { $clean = new \WP_Error( 'invalid_shape', __( 'Expected a single value.', 'customfields-com' ) ); } elseif ( is_string( $value ) && strlen( $value ) > 100000 ) { $clean = new \WP_Error( 'value_size', __( 'Field values may not exceed 100 KB.', 'customfields-com' ) ); } elseif ( is_string( $value ) && false !== strpos( $value, "\0" ) ) { $clean = new \WP_Error( 'invalid_value', __( 'Field values may not contain null bytes.', 'customfields-com' ) ); } else { $string = null === $value ? '' : (string) $value; if ( 'checkbox' === $type ) { $clean = in_array( $value, array( true, false, 1, 0, '1', '0', '', null ), true ) ? in_array( $value, array( true, 1, '1' ), true ) : new \WP_Error( 'invalid_checkbox', __( 'Choose an enabled or disabled value.', 'customfields-com' ) ); } elseif ( '' === $string ) { $clean = ''; } else { switch ( $type ) { case 'text': $clean = sanitize_text_field( $string ); break; case 'textarea': $clean = sanitize_textarea_field( $string ); break; case 'wysiwyg': $clean = wp_kses_post( $string ); break; case 'number': $clean = preg_match( '/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/D', $string ) && is_finite( (float) $string ) ? (float) $string : new \WP_Error( 'invalid_number', __( 'Enter a finite number.', 'customfields-com' ) ); break; case 'email': $clean = is_email( $string ) ? sanitize_email( $string ) : new \WP_Error( 'invalid_email', __( 'Enter a valid email address.', 'customfields-com' ) ); break; case 'url': $url = esc_url_raw( $string, array( 'http', 'https' ) ); $clean = $url && in_array( strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ), array( 'http', 'https' ), true ) && wp_parse_url( $url, PHP_URL_HOST ) ? $url : new \WP_Error( 'invalid_url', __( 'Enter an absolute http or https URL.', 'customfields-com' ) ); break; case 'select': case 'radio': $clean = array_key_exists( $string, $field['choices'] ?? array() ) ? $string : new \WP_Error( 'invalid_choice', __( 'Choose one of the configured choices.', 'customfields-com' ) ); break; case 'date': case 'datetime': $pattern = 'date' === $type ? '/^(\d{4})-(\d{2})-(\d{2})$/D' : '/^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d)$/D'; $valid = preg_match( $pattern, $string, $parts ) && checkdate( (int) $parts[2], (int) $parts[3], (int) $parts[1] ); $clean = $valid ? $string : new \WP_Error( 'invalid_date', __( 'Use a real date (YYYY-MM-DD), with HH:MM for date-time fields.', 'customfields-com' ) ); break; case 'time': $clean = preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/D', $string ) ? $string : new \WP_Error( 'invalid_time', __( 'Use a time in HH:MM format.', 'customfields-com' ) ); break; case 'image': case 'file': case 'post': case 'user': case 'taxonomy': $clean = self::reference( $value, $field ); break; case 'color': $clean = preg_match( '/^#[0-9a-fA-F]{6}$/D', $string ) ? strtolower( $string ) : new \WP_Error( 'invalid_color', __( 'Use a six-digit hex color such as #3366ff.', 'customfields-com' ) ); break; } } } if ( ! is_wp_error( $clean ) && '' !== $clean && 'number' === $type ) { if ( ( isset( $field['min'] ) && '' !== $field['min'] && $clean < $field['min'] ) || ( isset( $field['max'] ) && '' !== $field['max'] && $clean > $field['max'] ) ) { $clean = new \WP_Error( 'number_range', __( 'The number is outside the configured range.', 'customfields-com' ) ); } elseif ( isset( $field['step'] ) && 'any' !== $field['step'] && '' !== $field['step'] ) { $offset = ( $clean - ( isset( $field['min'] ) && '' !== $field['min'] ? $field['min'] : 0 ) ) / $field['step']; if ( abs( $offset - round( $offset ) ) > 0.000001 ) { $clean = new \WP_Error( 'number_step', __( 'The number does not match the configured step.', 'customfields-com' ) ); } } } if ( ! is_wp_error( $clean ) && is_string( $clean ) && '' !== $clean && in_array( $type, array( 'text', 'textarea', 'email', 'url', 'wysiwyg' ), true ) ) { $text = 'wysiwyg' === $type ? wp_strip_all_tags( $clean ) : $clean; $length = function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : preg_match_all( '/./us', $text ); if ( ( ! empty( $field['minlength'] ) && $length < $field['minlength'] ) || ( ! empty( $field['maxlength'] ) && $length > $field['maxlength'] ) ) { $clean = new \WP_Error( 'text_length', __( 'The text length is outside the configured limits.', 'customfields-com' ) ); } } /** Extensions must preserve errors for types they do not own. */ $clean = apply_filters( 'customfields_sanitize_value', $clean, $field, $raw, is_array( $context ) ? $context : array() ); if ( ! is_wp_error( $clean ) && ! empty( $field['required'] ) && ( '' === $clean || null === $clean || false === $clean || array() === $clean || ( 'wysiwyg' === $type && '' === trim( wp_strip_all_tags( $clean ) ) ) ) ) { return new \WP_Error( 'required_field', __( 'This field is required.', 'customfields-com' ) ); } return $clean; } /** Incompatible stored data must be migrated deliberately, never replaced by a browser's blank input. */ public static function storage_error( $value, $field ) { $error = null; if ( in_array( $field['type'] ?? '', self::CORE_TYPES, true ) ) { $test = $field; $test['required'] = false; $test['_checking_storage'] = true; $checked = self::sanitize( $value, $test ); if ( is_wp_error( $checked ) ) { $error = $checked; } } return apply_filters( 'customfields_storage_error', $error, $value, $field ); } /** Render an input, not a surrounding form. All core values are escaped here. */ public static function render( $field, $value, $input_name ) { if ( is_wp_error( self::storage_error( $value, $field ) ) ) { echo '

' . esc_html__( 'This stored value does not match the current field schema. The field is locked to preserve it. Restore a compatible field definition or explicitly migrate its metadata before editing.', 'customfields-com' ) . '

'; return; } $type = $field['type']; $id = 'cf-' . substr( md5( $input_name ), 0, 16 ); $required = ! empty( $field['required'] ) ? ' required aria-required="true"' : ''; $string = is_scalar( $value ) ? (string) $value : ''; $description = ! empty( $field['instructions'] ) ? ' aria-describedby="' . esc_attr( $id . '-description' ) . '"' : ''; $attrs = ' id="' . esc_attr( $id ) . '" name="' . esc_attr( $input_name ) . '"' . $required . $description; $length = ''; foreach ( array( 'minlength', 'maxlength' ) as $setting ) { if ( ! empty( $field[$setting] ) ) { $length .= ' ' . $setting . '="' . esc_attr( $field[$setting] ) . '"'; } } switch ( $type ) { case 'textarea': echo ''; break; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments are assembled above from fixed attribute names and esc_attr values; labels and values are escaped at their own sinks. case 'wysiwyg': // Gutenberg moves metaboxes after page load. Initialize the visual editor only in its live container. echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments use fixed names and escaped values above; editor content is escaped as textarea text. HTML length is validated as text on the server, not as raw textarea markup. break; case 'multiselect': case 'checkboxes': $values = is_array( $value ) ? $value : array(); unset( $values['__present'] ); $values = array_map( 'strval', $values ); echo ''; if ( 'multiselect' === $type ) { echo ' ' . esc_html( $label ) . ' '; } } echo 'multiselect' === $type ? '' : ''; break; case 'radio': echo '
' . esc_html( $field['label'] ) . ''; if ( empty( $field['required'] ) ) { echo ' '; } foreach ( $field['choices'] as $choice => $label ) { echo ' '; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments are assembled above from fixed attribute names and esc_attr values; labels and values are escaped at their own sinks. echo '
'; break; case 'select': echo ''; break; case 'image': case 'file': $url = $value ? wp_get_attachment_url( (int) $value ) : ''; echo '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments are assembled above from fixed attribute names and esc_attr values; labels and values are escaped at their own sinks. if ( $url && current_user_can( 'read_post', (int) $value ) ) { echo '' . esc_html( basename( $url ) ) . ''; } echo '

' . esc_html__( 'Stored as an attachment ID. Existing media remains in the Media Library when this field is cleared.', 'customfields-com' ) . '

'; break; case 'post': case 'user': case 'taxonomy': self::reference_control( $field, $value, $attrs ); break; case 'checkbox': echo ''; break; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments are assembled above from fixed attribute names and esc_attr values; labels and values are escaped at their own sinks. case 'number': case 'text': case 'email': case 'url': case 'date': case 'time': case 'datetime': $range = ''; if ( 'number' === $type ) { foreach ( array( 'min', 'max', 'step' ) as $setting ) { $v = $field[$setting] ?? ( 'step' === $setting ? 'any' : '' ); if ( '' !== $v ) { $range .= ' ' . $setting . '="' . esc_attr( $v ) . '"'; } } } echo ''; break; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments are assembled above from fixed attribute names and esc_attr values; labels and values are escaped at their own sinks. case 'color': echo ''; break; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute fragments are assembled above from fixed attribute names and esc_attr values; labels and values are escaped at their own sinks. default: if ( array_key_exists( $type, self::types() ) && has_action( 'customfields_render_field' ) ) { do_action( 'customfields_render_field', $field, $value, $input_name ); } else { echo '

' . esc_html__( 'This field needs an extension that is currently inactive. Its stored value will be preserved.', 'customfields-com' ) . '

'; } } do_action( 'customfields_after_render_field', $field, $value, $input_name ); } private static function reference_control( $field, $value, $attrs ) { $items = array(); if ( 'post' === $field['type'] ) { $posts = get_posts( array( 'post_type' => $field['post_types'] ?? array( 'post' ), 'post_status' => array( 'publish', 'private', 'draft', 'pending', 'future' ), 'numberposts' => 100, 'orderby' => 'title', 'order' => 'ASC', 'suppress_filters' => false ) ); if ( $value ) { $posts[] = get_post( (int) $value ); } foreach ( $posts as $post ) { if ( $post && current_user_can( 'read_post', $post->ID ) ) { $items[$post->ID] = $post->post_title . ' (#' . $post->ID . ')'; } } } elseif ( 'user' === $field['type'] ) { $users = current_user_can( 'list_users' ) ? get_users( array( 'number' => 100, 'orderby' => 'display_name', 'order' => 'ASC' ) ) : array( wp_get_current_user() ); if ( $value && current_user_can( 'list_users' ) ) { $users[] = get_userdata( (int) $value ); } foreach ( $users as $user ) { if ( $user && $user->ID ) { $items[$user->ID] = $user->display_name . ' (#' . $user->ID . ')'; } } } else { $taxonomy = get_taxonomy( $field['taxonomy'] ?? '' ); if ( $taxonomy && current_user_can( $taxonomy->cap->assign_terms ) ) { $terms = get_terms( array( 'taxonomy' => $taxonomy->name, 'hide_empty' => false, 'number' => 100 ) ); if ( ! is_wp_error( $terms ) ) { if ( $value ) { $terms[] = get_term( (int) $value, $taxonomy->name ); } foreach ( $terms as $term ) { if ( $term && ! is_wp_error( $term ) ) { $items[$term->term_id] = $term->name . ' (#' . $term->term_id . ')'; } } } } } // A user who cannot inspect the stored reference receives a preservation lock, not a blank select. if ( $value && ! isset( $items[(int) $value] ) ) { echo '

' . esc_html__( 'You cannot inspect this selection. Its stored value will be preserved.', 'customfields-com' ) . '

'; return; } echo '

' . esc_html__( 'Shows up to 100 permitted items, plus the current selection. Stored as an ID.', 'customfields-com' ) . '

'; } }