display label. */ public static function get_supported_types(): array { $types = array( // P0 types. 'text' => __( 'Text', 'repeaterkit' ), 'textarea' => __( 'Text Area', 'repeaterkit' ), 'number' => __( 'Number', 'repeaterkit' ), 'email' => __( 'Email', 'repeaterkit' ), 'url' => __( 'URL', 'repeaterkit' ), 'password' => __( 'Password', 'repeaterkit' ), 'true_false' => __( 'True / False', 'repeaterkit' ), 'select' => __( 'Select', 'repeaterkit' ), 'checkbox' => __( 'Checkbox', 'repeaterkit' ), 'radio' => __( 'Radio Button', 'repeaterkit' ), 'color_picker' => __( 'Color Picker', 'repeaterkit' ), 'date_picker' => __( 'Date Picker', 'repeaterkit' ), 'image' => __( 'Image', 'repeaterkit' ), 'file' => __( 'File', 'repeaterkit' ), // P1 types. 'wysiwyg' => __( 'WYSIWYG Editor', 'repeaterkit' ), 'time_picker' => __( 'Time Picker', 'repeaterkit' ), 'date_time_picker' => __( 'Date Time Picker', 'repeaterkit' ), 'range' => __( 'Range', 'repeaterkit' ), 'button_group' => __( 'Button Group', 'repeaterkit' ), 'oembed' => __( 'oEmbed', 'repeaterkit' ), ); return (array) apply_filters( 'repeaterkit/supported_subfield_types', $types ); } /** * Render an ACF subfield using ACF's native field rendering engine. * * @param array $subfield Subfield configuration array. * @param string $prefix Input name prefix (e.g. acf[field_rep][r_abc]). * @param mixed $value Current subfield value. * @param string $context Render context ('table' or 'block'). * @param string $row_id Unique row ID string. * @return void */ public static function render_subfield( array $subfield, string $prefix, $value, string $context = 'block', $row_id = '0' ): void { $subfield['prefix'] = $prefix; $subfield['value'] = $value; // Handle WYSIWYG unique ID per row to avoid TinyMCE instance collisions. if ( 'wysiwyg' === $subfield['type'] ) { $subfield['id'] = 'acf-' . $subfield['key'] . '-' . $row_id; } if ( 'table' === $context ) { // For table layout, render inside a td without outer label wrapper. echo ''; if ( function_exists( 'acf_render_field_wrap' ) ) { acf_render_field_wrap( $subfield, 'div', 'none' ); } elseif ( function_exists( 'acf_render_field' ) ) { acf_render_field( $subfield ); } echo ''; } elseif ( function_exists( 'acf_render_field_wrap' ) ) { // For block or row layout, standard ACF wrapper. acf_render_field_wrap( $subfield, 'div', 'label' ); } } /** * Sanitize an individual subfield value strictly according to its field type. * * @param mixed $value Raw submitted input value. * @param array $subfield Subfield definition array. * @return mixed Sanitized value. */ public static function sanitize_value( $value, array $subfield ) { if ( null === $value || '' === $value ) { return ''; } $type = $subfield['type'] ?? 'text'; // Guard: Reject array payloads submitted for scalar field types. if ( is_array( $value ) && ! in_array( $type, array( 'checkbox', 'select' ), true ) ) { return ''; } switch ( $type ) { case 'number': case 'range': if ( ! is_numeric( $value ) ) { return 0; } $num = ( false !== strpos( (string) $value, '.' ) ) ? (float) $value : (int) $value; if ( isset( $subfield['min'] ) && '' !== $subfield['min'] && $num < (float) $subfield['min'] ) { $num = ( false !== strpos( (string) $subfield['min'], '.' ) ) ? (float) $subfield['min'] : (int) $subfield['min']; } if ( isset( $subfield['max'] ) && '' !== $subfield['max'] && $num > (float) $subfield['max'] ) { $num = ( false !== strpos( (string) $subfield['max'], '.' ) ) ? (float) $subfield['max'] : (int) $subfield['max']; } return $num; case 'email': return sanitize_email( (string) $value ); case 'url': return esc_url_raw( (string) $value ); case 'true_false': return ! empty( $value ) ? 1 : 0; case 'textarea': return sanitize_textarea_field( (string) $value ); case 'wysiwyg': return wp_kses_post( (string) $value ); case 'color_picker': $hex = sanitize_hex_color( (string) $value ); return $hex ? $hex : sanitize_text_field( (string) $value ); case 'date_picker': case 'time_picker': case 'date_time_picker': return sanitize_text_field( (string) $value ); case 'image': case 'file': return self::sanitize_attachment_id( $value ); case 'select': case 'radio': case 'button_group': $choices = ! empty( $subfield['choices'] ) && is_array( $subfield['choices'] ) ? array_keys( $subfield['choices'] ) : array(); if ( ! empty( $choices ) ) { if ( is_array( $value ) ) { $valid_vals = array_filter( $value, function ( $v ) use ( $choices ) { return in_array( (string) $v, array_map( 'strval', $choices ), true ); } ); return array_values( array_map( 'sanitize_text_field', $valid_vals ) ); } return in_array( (string) $value, array_map( 'strval', $choices ), true ) ? sanitize_text_field( (string) $value ) : ''; } if ( is_array( $value ) ) { return array_map( 'sanitize_text_field', $value ); } return sanitize_text_field( (string) $value ); case 'checkbox': $choices = ! empty( $subfield['choices'] ) && is_array( $subfield['choices'] ) ? array_keys( $subfield['choices'] ) : array(); $values = is_array( $value ) ? $value : array( $value ); if ( ! empty( $choices ) ) { $values = array_filter( $values, function ( $v ) use ( $choices ) { return in_array( (string) $v, array_map( 'strval', $choices ), true ); } ); } return array_values( array_map( 'sanitize_text_field', $values ) ); case 'text': default: // Fallback text sanitization (with 10,000 char maximum length guard). $text_val = (string) $value; if ( strlen( $text_val ) > 10000 ) { $text_val = substr( $text_val, 0, 10000 ); } return sanitize_text_field( $text_val ); } } /** * Verify that an attachment ID belongs to a valid attachment post. * Prevents IDOR by verifying post_type === 'attachment' and parent post read permissions. * * @param mixed $value Stored or submitted attachment ID. * @return int Validated attachment ID, or 0 if invalid. */ public static function sanitize_attachment_id( $value ): int { if ( is_array( $value ) ) { $value = $value['ID'] ?? ( $value['id'] ?? 0 ); } $attachment_id = (int) $value; if ( $attachment_id <= 0 ) { return 0; } $post = get_post( $attachment_id ); if ( ! $post || 'attachment' !== $post->post_type ) { return 0; } // Verify accessibility if attachment is attached to a private or draft parent. if ( $post->post_parent > 0 ) { $parent = get_post( $post->post_parent ); if ( $parent && 'publish' !== $parent->post_status && ! current_user_can( 'read_post', $post->post_parent ) ) { return 0; } } // Verify accessibility if attachment itself is private. if ( 'private' === $post->post_status && ! current_user_can( 'read_post', $attachment_id ) ) { return 0; } return $attachment_id; } /** * Format a subfield value for template output. * * @param mixed $value Raw stored value. * @param array $subfield Subfield configuration array. * @param mixed $post_id Current post ID. * @return mixed Formatted value. */ public static function format_value( $value, array $subfield, $post_id ) { if ( null === $value || '' === $value ) { return $value; } $type = $subfield['type'] ?? 'text'; switch ( $type ) { case 'image': case 'file': $attachment_id = (int) $value; if ( $attachment_id <= 0 ) { return false; } $return_format = $subfield['return_format'] ?? 'array'; if ( 'url' === $return_format ) { $url = wp_get_attachment_url( $attachment_id ); return $url ? $url : false; } elseif ( 'id' === $return_format ) { return $attachment_id; } // Default 'array' return format. $attachment = acf_get_attachment( $attachment_id ); return $attachment ? $attachment : false; case 'wysiwyg': // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Standard WordPress post content filter. return apply_filters( 'the_content', (string) $value ); case 'number': return is_numeric( $value ) ? ( false !== strpos( (string) $value, '.' ) ? (float) $value : (int) $value ) : $value; case 'true_false': return (bool) $value; default: break; } return apply_filters( 'repeaterkit/format_subfield_value', $value, $subfield, $post_id ); } }