array(), 'taxonomy' => array()); public static function boot() { add_action('init', array(__CLASS__, 'register'), 8); add_action('admin_menu', array(__CLASS__, 'menu'), 25); add_action('admin_enqueue_scripts', array(__CLASS__, 'assets')); } public static function assets($hook) { if (strpos($hook,'customfields')!==false) { wp_enqueue_style('customfields-content-platform',CUSTOMFIELDS_URL.'assets/content-platform.css',array(),CUSTOMFIELDS_VERSION); } } public static function definitions() { $value = get_option(self::OPTION, array()); return is_array($value) ? array_merge(array('post' => array(), 'taxonomy' => array()), $value) : array('post' => array(), 'taxonomy' => array()); } public static function hash($value = null) { return hash('sha256', wp_json_encode($value === null ? self::definitions() : $value)); } /** Preserve the exact review proof; malformed text must not become a current fingerprint. */ private static function submitted_hash($raw) { if (!is_string($raw)) { return ''; } $clean = sanitize_text_field($raw); return $clean === $raw && preg_match('/^[a-f0-9]{64}$/D', $clean) ? $clean : ''; } private static function error($message) { return new \WP_Error('content_type_definition', $message); } public static function validate($raw, $before = null, $portable = false) { if (!is_array($raw) || count($raw) > 2) { return self::error(__('Expected post types and taxonomies.', 'customfields-com')); } $out = array('post' => array(), 'taxonomy' => array()); $before = $before === null ? self::definitions() : $before; foreach ($out as $kind => $_) { $items = $raw[$kind] ?? array(); if (!is_array($items) || count($items) > 30) { return self::error(__('Each site supports at most 30 custom post types and 30 taxonomies.', 'customfields-com')); } foreach ($items as $item) { if (!is_array($item)) { return self::error(__('Each definition must be an object.', 'customfields-com')); } $slug = $item['slug'] ?? ''; $limit = $kind === 'post' ? 20 : 32; if (!is_string($slug) || !preg_match('/^[a-z][a-z0-9_]{0,' . ($limit - 1) . '}$/D', $slug) || preg_match('/^(wp_|customfields|cf_)/', $slug) || in_array($slug, array('post','page','attachment','revision','nav_menu_item','custom_css','customize_changeset','category','post_tag','nav_menu','link_category','post_format','author','name','type','taxonomy','term','feed','search','s','p','page_id'), true)) { return self::error(__('Choose a unique lowercase slug; WordPress and CustomFields.com names are reserved.', 'customfields-com')); } if (isset($out[$kind][$slug])) { return self::error(__('Definition slugs must be unique.', 'customfields-com')); } $registered = $kind === 'post' ? post_type_exists($slug) : taxonomy_exists($slug); if (!$portable && $registered && !isset(self::$owned[$kind][$slug])) { return self::error(sprintf(/* translators: %s: Content type or taxonomy slug. */ __('The name %s is already registered by WordPress or another plugin.', 'customfields-com'), $slug)); } $clean = array('slug' => $slug); foreach (array('label','singular') as $key) { $value = $item[$key] ?? ''; if (!is_string($value) || trim($value) === '' || strlen($value) > 80 || sanitize_text_field($value) !== $value) { return self::error(__('Use plain labels of 1–80 characters.', 'customfields-com')); } $clean[$key] = $value; } foreach (array('public','hierarchical') as $key) { $value = $item[$key] ?? false; if (!in_array($value, array(false,true,0,1,'0','1',''), true)) { return self::error(__('Visibility settings must be boolean.', 'customfields-com')); } $clean[$key] = (bool) $value; } $access = $item['access'] ?? 'editors'; if (!in_array($access, array('editors','administrators'), true)) { return self::error(__('Choose editors or administrators for editing access.', 'customfields-com')); } $clean['access'] = $access; if ($kind === 'taxonomy') { $types = $item['post_types'] ?? array(); if (is_string($types)) { $types = preg_split('/[\s,]+/', trim($types), -1, PREG_SPLIT_NO_EMPTY); } if (!is_array($types) || !$types || count($types) > 30) { return self::error(__('Choose 1–30 post types for the taxonomy.', 'customfields-com')); } foreach ($types as $type) { if (!is_string($type) || (!$portable && !post_type_exists($type) && !isset($out['post'][$type])) || !preg_match('/^[a-z0-9_-]{1,20}$/D', $type) || in_array($type, array('revision','attachment','nav_menu_item','custom_css','customize_changeset'), true) || strpos($type, 'wp_') === 0) { return self::error(__('Taxonomies must use registered editable content types.', 'customfields-com')); } } $clean['post_types'] = array_values(array_unique($types)); } $out[$kind][$slug] = $clean; } } return $out; } public static function register() { $definitions = self::definitions(); foreach ($definitions['post'] as $slug => $item) { if (post_type_exists($slug)) { continue; } $args = array('labels' => array('name'=>$item['label'],'singular_name'=>$item['singular']), 'public'=>(bool)$item['public'], 'show_ui'=>true, 'show_in_rest'=>true, 'hierarchical'=>(bool)$item['hierarchical'], 'supports'=>array('title','editor','thumbnail','excerpt','revisions','custom-fields'), 'has_archive'=>(bool)$item['public'], 'rewrite'=>$item['public'] ? array('slug'=>$slug) : false, 'map_meta_cap'=>true, 'capability_type'=>'post'); if ($item['access'] === 'administrators') { $args['capability_type'] = array($slug, $slug . 's'); foreach (array('edit_posts','edit_others_posts','publish_posts','read_private_posts','delete_posts','delete_private_posts','delete_published_posts','delete_others_posts','edit_private_posts','edit_published_posts','create_posts') as $cap) { $args['capabilities'][$cap] = 'manage_options'; } } $result = register_post_type($slug, $args); if (!is_wp_error($result)) { self::$owned['post'][$slug] = true; } } foreach ($definitions['taxonomy'] as $slug => $item) { if (taxonomy_exists($slug)) { continue; } $admin = $item['access'] === 'administrators'; $result = register_taxonomy($slug, $item['post_types'], array('labels'=>array('name'=>$item['label'],'singular_name'=>$item['singular']), 'public'=>(bool)$item['public'], 'show_ui'=>true, 'show_in_rest'=>true, 'hierarchical'=>(bool)$item['hierarchical'], 'rewrite'=>$item['public'] ? array('slug'=>$slug) : false, 'capabilities'=>array('manage_terms'=>$admin?'manage_options':'manage_categories','edit_terms'=>$admin?'manage_options':'manage_categories','delete_terms'=>$admin?'manage_options':'manage_categories','assign_terms'=>$admin?'manage_options':'edit_posts'))); if (!is_wp_error($result)) { self::$owned['taxonomy'][$slug] = true; } } if (get_option('customfields_rewrite_pending')) { flush_rewrite_rules(false); delete_option('customfields_rewrite_pending'); } } public static function save($raw, $expected) { if (!current_user_can('manage_options')) { return new \WP_Error('content_type_permission', __('Only administrators can change content definitions.', 'customfields-com')); } global $wpdb; $row = $wpdb->get_row($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", self::OPTION)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. $before = $row ? maybe_unserialize($row->option_value) : array('post'=>array(),'taxonomy'=>array()); if (!is_array($before) || !is_string($expected) || !hash_equals(self::hash($before), $expected)) { return new \WP_Error('content_type_conflict', __('Definitions changed in another window. Copy your edits before reloading.', 'customfields-com')); } $clean = self::validate($raw, $before); if (is_wp_error($clean)) { return $clean; } $encoded = maybe_serialize($clean); if ($row && $row->option_value === $encoded) { return $clean; } $result = $row ? $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET option_value=%s WHERE option_name=%s AND BINARY option_value=BINARY %s", $encoded, self::OPTION, $row->option_value)) : $wpdb->query($wpdb->prepare("INSERT IGNORE INTO {$wpdb->options} (option_name,option_value,autoload) VALUES (%s,%s,'no')", self::OPTION, $encoded)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. if ($result !== 1) { return new \WP_Error('content_type_conflict', __('The definitions could not be saved, or another writer changed them. Your entries remain here.', 'customfields-com')); } wp_cache_delete(self::OPTION, 'options'); wp_cache_delete('alloptions','options'); wp_cache_delete('notoptions','options'); update_option('customfields_rewrite_pending', 1, false); return $clean; } public static function menu() { add_submenu_page('customfields', __('Content types','customfields-com'), __('Content types','customfields-com'), 'manage_options', 'customfields-content-types', array(__CLASS__,'page')); } public static function page() { if (!current_user_can('manage_options')) { wp_die(esc_html__('Not permitted.','customfields-com')); } $definitions = self::definitions(); $error = null; $editing = null; $kind = 'post'; if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Strict comparison with the fixed HTTP method; the value is never stored or rendered. $nonce = isset($_POST['_wpnonce']) && is_string($_POST['_wpnonce']) ? sanitize_text_field(wp_unslash($_POST['_wpnonce'])) : ''; if (!wp_verify_nonce($nonce, 'customfields_content_types')) { wp_die(esc_html__('Your session expired. Reload this page before continuing.', 'customfields-com'), '', array('response' => 403)); } $submitted_kind = isset($_POST['kind']) && is_string($_POST['kind']) ? filter_var(wp_unslash($_POST['kind']), FILTER_VALIDATE_REGEXP, array('options' => array('regexp' => '/^(post|taxonomy)$/D'))) : false; $kind = 'taxonomy' === $submitted_kind ? 'taxonomy' : 'post'; $editing = isset($_POST['definition']) && is_array($_POST['definition']) ? wp_unslash($_POST['definition']) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- save() calls validate() to check and sanitize each supported definition field before any write; the error form separately sanitizes and escapes its plain text inputs. $submitted_hash = isset($_POST['definition_hash']) && is_string($_POST['definition_hash']) ? filter_var(wp_unslash($_POST['definition_hash']), FILTER_VALIDATE_REGEXP, array('options' => array('regexp' => '/^[a-f0-9]{64}$/D'))) : false; $remove = isset($_POST['remove']) && is_string($_POST['remove']) ? filter_var(wp_unslash($_POST['remove']), FILTER_VALIDATE_REGEXP, array('options' => array('regexp' => '/^1$/D'))) : false; $slug = is_string($editing['slug'] ?? null) ? sanitize_key($editing['slug']) : ''; if (false === $submitted_kind || '' === $slug || $slug !== ($editing['slug'] ?? null) || (isset($_POST['remove']) && '1' !== $remove)) { $result = self::error(__('Choose a valid definition kind and stable slug.', 'customfields-com')); } else { $next = $definitions; if ('1' === $remove) { unset($next[$kind][$slug]); } else { $next[$kind][$slug] = $editing; } $result = self::save($next, self::submitted_hash($submitted_hash)); } if (is_wp_error($result)) { $error = $result; } else { $definitions = $result; $editing = null; echo '

' . esc_html__('Definitions saved. The registration is available on the next request. Existing content is retained.', 'customfields-com') . '

'; } } else { // Read-only selection; administrator access is required above and this branch never writes. $selected_kind = isset($_GET['kind']) && is_string($_GET['kind']) ? sanitize_key(wp_unslash($_GET['kind'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Administrator-only read-only selection of an existing definition. $selected_slug = isset($_GET['edit']) && is_string($_GET['edit']) ? sanitize_key(wp_unslash($_GET['edit'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Administrator-only read-only selection of an existing definition. if (in_array($selected_kind, array('post', 'taxonomy'), true)) { $kind = $selected_kind; $editing = $definitions[$kind][$selected_slug] ?? null; } } echo '

'.esc_html__('Content types','customfields-com').'

'.esc_html__('Create structured content and taxonomies, then attach your field groups. Removing a registration preserves all existing content.','customfields-com').'

'; if ($error) { echo ''; } echo ''; $shown = 0; foreach ($definitions as $type => $items) { if (!in_array($type, array('post', 'taxonomy'), true) || !is_array($items)) { continue; } foreach ($items as $stored_slug => $item) { if (!is_string($stored_slug) || !is_array($item) || !is_string($item['label'] ?? null)) { continue; } $slug = sanitize_key($stored_slug); if ('' === $slug || $slug !== $stored_slug) { continue; } $label = sanitize_text_field($item['label']); $edit_url = add_query_arg(array('page' => 'customfields-content-types', 'kind' => $type, 'edit' => $slug), admin_url('admin.php')); $visibility = !empty($item['public']) ? __('Public', 'customfields-com') : __('Admin only', 'customfields-com'); $kind_label = $type === 'post' ? __('Content type', 'customfields-com') : __('Taxonomy', 'customfields-com'); echo ''; $shown++; } } if (!$shown) { echo ''; } echo '
'.esc_html__('Name','customfields-com').''.esc_html__('Kind','customfields-com').''.esc_html__('Visibility','customfields-com').'
' . esc_html($label) . ' ' . esc_html($slug) . '' . esc_html($kind_label) . '' . esc_html($visibility) . '
' . esc_html__('No custom content types or taxonomies yet. Add one below, or use your existing posts and pages with a field group.', 'customfields-com') . '

'.esc_html($editing ? __('Edit definition','customfields-com') : __('Add a definition','customfields-com')).'

'; wp_nonce_field('customfields_content_types'); $hash = $error ? self::submitted_hash($submitted_hash) : self::hash($definitions); echo ''; echo '

'; foreach (array('slug'=>__('Stable slug','customfields-com'),'label'=>__('Plural label','customfields-com'),'singular'=>__('Singular label','customfields-com')) as $key=>$label) { echo '

'; } $display_types = $editing['post_types'] ?? 'post'; if (is_array($display_types)) { $display_types = implode(', ', array_filter($display_types, 'is_string')); } $display_types = is_string($display_types) ? sanitize_text_field($display_types) : ''; $display_access = is_string($editing['access'] ?? null) ? sanitize_key($editing['access']) : 'editors'; echo '

'; foreach (array('public'=>__('Publicly visible and queryable','customfields-com'),'hierarchical'=>__('Hierarchical (parent/child)','customfields-com')) as $key=>$label) { echo '

'; } echo '

'; submit_button(__('Save definition','customfields-com')); if ($editing && !$error) { echo ''; } echo '
'; } }