base, array('post', 'profile', 'user-edit', 'term', 'edit-tags'), true); $field_page = $hook === 'toplevel_page_customfields' || strpos($hook, 'customfields-pro-options') !== false || strpos($hook, 'customfields-bulk') !== false; if (!$field_screen && !$field_page) { return; } wp_enqueue_media(); wp_enqueue_editor(); wp_enqueue_script('customfields-richtext', CUSTOMFIELDS_URL . 'assets/richtext.js', array('editor', 'wp-i18n'), CUSTOMFIELDS_VERSION, true); wp_set_script_translations('customfields-richtext', 'customfields-com', CUSTOMFIELDS_PATH . 'languages'); wp_enqueue_style('customfields-pro', CUSTOMFIELDS_LOCAL_URL . 'admin.css', array(), CUSTOMFIELDS_LOCAL_VERSION); wp_enqueue_script('customfields-pro', CUSTOMFIELDS_LOCAL_URL . 'admin.js', array('wp-i18n', 'customfields-richtext'), CUSTOMFIELDS_LOCAL_VERSION, true); wp_set_script_translations('customfields-pro', 'customfields-com', CUSTOMFIELDS_PATH . 'languages'); if (strpos($hook, 'customfields-pro-options') !== false || isset($_POST['options_page_slug'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Display-only options-page selection; save handlers independently verify page capability and action nonce. wp_enqueue_script('customfields-pro-options', CUSTOMFIELDS_LOCAL_URL . 'options.js', array('customfields-pro', 'wp-i18n'), CUSTOMFIELDS_LOCAL_VERSION, true); wp_set_script_translations('customfields-pro-options', 'customfields-com', CUSTOMFIELDS_PATH . 'languages'); wp_localize_script('customfields-pro-options', 'CustomFieldsOptions', array('ajaxUrl' => admin_url('admin-ajax.php'))); } wp_localize_script('customfields-pro', 'customfieldsProLabels', array( 'row' => __('Row', 'customfields-com'), 'rows' => __('rows', 'customfields-com'), 'maximum' => __('Maximum reached', 'customfields-com'), 'up' => __('Move up', 'customfields-com'), 'down' => __('Move down', 'customfields-com'), 'remove' => __('Remove', 'customfields-com'), 'collapse' => __('Collapse', 'customfields-com'), 'expand' => __('Expand', 'customfields-com'), 'chooseImages' => __('Choose gallery images', 'customfields-com'), 'newField' => __('New child field', 'customfields-com'), 'fieldLabel' => __('Label', 'customfields-com'), 'fieldName' => __('Name', 'customfields-com'), 'fieldType' => __('Type', 'customfields-com'), 'required' => __('Required', 'customfields-com'), 'choices' => __('Choices (value : label, one per line)', 'customfields-com'), 'defaultValue' => __('Default value', 'customfields-com'), 'minRows' => __('Minimum rows', 'customfields-com'), 'maxRows' => __('Maximum rows', 'customfields-com'), 'addChild' => __('Add child field', 'customfields-com'), 'maxChildren' => __('A container supports up to 20 child fields.', 'customfields-com'), 'invalidJSON' => __('Provide an array of child fields.', 'customfields-com'), )); } private static function authorize() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You cannot manage these settings.', 'customfields-com'), '', array('response' => 403)); } } private static function page_config($group) { return array_merge(array('slug' => 'site', 'title' => __('Site options', 'customfields-com'), 'capability' => 'manage_options'), $group['options_page'] ?? array()); } private static function menu_slug($slug) { return $slug === 'site' ? 'customfields-pro-options' : 'customfields-pro-options-' . $slug; } private static function selected_page() { $slug = isset($_POST['options_page_slug']) && is_string($_POST['options_page_slug']) ? sanitize_key(wp_unslash($_POST['options_page_slug'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Display-only options-page selection; save handlers independently verify page capability and action nonce. if ($slug === '') { $menu = isset($_GET['page']) && is_string($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing, filtering or pagination; no mutation is authorized by this query parameter. Object access remains checked by the renderer. $slug = is_string($menu) && strpos($menu, 'customfields-pro-options-') === 0 ? substr($menu, strlen('customfields-pro-options-')) : 'site'; } return is_string($slug) && preg_match('/^[a-z][a-z0-9_-]{0,39}$/D', $slug) ? $slug : ''; } /** Only groups explicitly delegated to the current user are rendered or written. */ private static function groups($slug = null) { if ($slug === null) { $slug = self::selected_page(); } return array_values(array_filter(customfields_get_groups(), static function ($group) use ($slug) { $config = self::page_config($group); return ($group['target'] ?? 'post') === 'options' && $config['slug'] === $slug && current_user_can($config['capability']); })); } private static function authorize_options() { if (self::selected_page() === '' || (!self::groups() && !(self::selected_page() === 'site' && current_user_can('manage_options')))) { wp_die(esc_html__('You cannot manage this options page.', 'customfields-com'), '', array('response' => 403)); } } private static function values_hash($values, $groups) { $visible = array(); foreach ($groups as $group) { foreach ($group['fields'] as $field) { if (array_key_exists($field['name'], $values)) { $visible[$field['name']] = $values[$field['name']]; } } } return hash('sha256', serialize($visible)); } /** Optimistic compare-and-swap preserves changes to other options pages. */ private static function write_values($before, $updates, $groups) { global $wpdb; $name = 'customfields_pro_values'; for ($attempt = 0; $attempt < 3; $attempt++) { $row = $wpdb->get_row($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name), ARRAY_A); // 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. if ($wpdb->last_error) { return new \WP_Error('options_read', __('Site options could not be read. Nothing was saved.', 'customfields-com')); } $latest = $row ? maybe_unserialize($row['option_value']) : array(); if (!is_array($latest) || self::values_hash($latest, $groups) !== self::values_hash($before, $groups)) { return new \WP_Error('options_conflict', __('Values on this page changed while you were editing. Reload the page and review them before saving.', 'customfields-com')); } $next = array_replace($latest, $updates); if ($next === $latest) { return true; } if (!$row) { wp_cache_delete($name, 'options'); wp_cache_delete('notoptions', 'options'); if (add_option($name, $next, '', false)) { return true; } continue; } $changed = $wpdb->query($wpdb->prepare("UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s", maybe_serialize($next), $name, $row['option_value'])); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic workflow write with explicit formats/prepared values; surrounding transaction performs cache invalidation after commit. wp_cache_delete($name, 'options'); wp_cache_delete('alloptions', 'options'); if ($changed === 1) { do_action('updated_option', $name, $latest, $next); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentionally emit the existing WordPress option lifecycle hook after the atomic option write. return true; } if ($changed === false) { return new \WP_Error('options_write', __('Site options could not be saved. Please try again.', 'customfields-com')); } } return new \WP_Error('options_busy', __('Site options changed during saving. Reload and try again.', 'customfields-com')); } private static function notice($kind, $message) { set_transient('customfields_pro_notice_' . get_current_user_id(), array('kind' => $kind, 'message' => $message), 120); } private static function show_notice() { $key = 'customfields_pro_notice_' . get_current_user_id(); $notice = get_transient($key); if (!is_array($notice)) { return; } delete_transient($key); echo '

' . esc_html($notice['message']) . '

'; } private static function redirect($page) { wp_safe_redirect(admin_url('admin.php?page=' . $page)); exit; } /** Echo only this user's submitted work; never fetch or retain removed fields. */ public static function recovery_panel($request, $kind = 'options') { if (!is_array($request)) { return; } if (isset($request['values_json']) && is_string($request['values_json'])) { $text = $request['values_json']; } else { $values = $kind === 'options' ? ($request['customfields_pro_values'] ?? array()) : ($request['customfields_frontend_values'][absint($request['post_id'] ?? 0)][$request['group_key'] ?? ''] ?? array()); $text = wp_json_encode($values, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE); } $recovered = $request[$kind === 'options' ? 'customfields_pro_recovery_json' : 'cfp_recovered'] ?? array(); if ($recovered) { $text = wp_json_encode(array('submitted_json' => $text, 'recovered_json' => $recovered), JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE); } $text = self::recovery_text($text); if ($text === '' || $text === '[]') { return; } echo '
' . esc_html__('Copy of your submitted work', 'customfields-com') . '

' . esc_html__('Copy these values before leaving this page. Fields that were removed or are no longer editable are included only for recovery and cannot be saved here.', 'customfields-com') . '

'; } /** Recovery may retain an old conflict token, but must never repair it into a valid one. */ private static function recovery_hash($value) { if (!is_string($value)) { return ''; } $clean = sanitize_text_field($value); return $clean === $value && preg_match('/^[a-f0-9]{64}$/D', $clean) ? $clean : ''; } /** * Recovery is editable source text, not HTML or a value ready for storage. * Validate its byte envelope without destroying JSON strings containing rich * text, percent signs or newlines. Invalid JSON remains available to correct; * options_input()/Forms::submitted() parse it and field sanitizers run on save. */ public static function recovery_text($value) { if (!is_string($value) || strlen($value) > 1048576) { return ''; } if (wp_check_invalid_utf8($value) !== $value || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value)) { return ''; } return $value; } /** Read only recognized options controls; nested fields are sanitized by schema. */ private static function options_http_request() { if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce'])) { return array(); } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'customfields_pro_options')) { return array(); } $request = array(); foreach (array('_wpnonce', 'options_page_slug', 'customfields_pro_schema', 'customfields_pro_values_hash', 'customfields_pro_complete', 'options_rebase', 'options_confirm_review', 'options_intent', 'intent') as $name) { if (!isset($_POST[$name])) { continue; } if (!is_string($_POST[$name])) { $request[$name] = null; continue; } $clean = sanitize_text_field(wp_unslash($_POST[$name])); // A malformed proof or completion marker must never be repaired into a valid one. $request[$name] = $name === '_wpnonce' || $clean === wp_unslash($_POST[$name]) ? $clean : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The second read is used only for exact comparison with the sanitized control, never output or storage. } if (isset($_POST['values_json'])) { $request['values_json'] = is_string($_POST['values_json']) ? wp_unslash($_POST['values_json']) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Authenticated JSON transport; options_input enforces the 1 MB/depth/array limits, then options_request_validated sanitizes every field against its registered schema before the atomic write. } foreach (array('customfields_pro_values', 'customfields_pro_recovery_json') as $name) { if (isset($_POST[$name])) { $request[$name] = is_array($_POST[$name]) ? wp_unslash($_POST[$name]) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Authenticated structured fields; options_input validates the complete envelope and every persisted value passes its field sanitizer. Generic text sanitation would corrupt rich/nested fields. } } return $request; } public static function options_page($recovery = null, $error = null, $review = false) { $groups = self::groups(); if ($recovery && !$groups && is_user_logged_in() && is_string($recovery['_wpnonce'] ?? null) && wp_verify_nonce(sanitize_text_field($recovery['_wpnonce']), 'customfields_pro_options')) { echo '

' . esc_html__('Unsaved site options', 'customfields-com') . '

' . esc_html__('This options page is no longer available to you. Nothing was saved.', 'customfields-com') . '

'; self::recovery_panel($recovery); echo '
'; return; } self::authorize_options(); $config = $groups ? self::page_config($groups[0]) : array('title' => __('Site options', 'customfields-com')); echo '

' . esc_html($config['title']) . '

' . esc_html__('Edit shared content here and reuse it across your site.', 'customfields-com') . '

'; self::show_notice(); $groups = self::groups(); if (!$groups) { echo '

' . esc_html__('Create a field group and select Site options as its target to start.', 'customfields-com') . '

' . esc_html__('Manage field groups', 'customfields-com') . '

'; return; } $values = get_option('customfields_pro_values', array()); if (!is_array($values)) { $values = array(); } $submitted = is_array($recovery) ? self::options_input($recovery) : array(); $raw_recovery = array(); if (is_wp_error($submitted)) { $submitted = is_array($recovery['customfields_pro_values'] ?? null) && strlen(wp_json_encode($recovery['customfields_pro_values'])) <= 1048576 ? $recovery['customfields_pro_values'] : array(); $raw_recovery = is_array($recovery['customfields_pro_recovery_json'] ?? null) && strlen(wp_json_encode($recovery['customfields_pro_recovery_json'])) <= 1048576 ? $recovery['customfields_pro_recovery_json'] : array(); } $schema_hash = hash('sha256', wp_json_encode($groups)); $recovery_schema_hash = self::recovery_hash($recovery['customfields_pro_schema'] ?? null); $recovery_values_hash = self::recovery_hash($recovery['customfields_pro_values_hash'] ?? null); $same_schema = !$recovery || $recovery_schema_hash === $schema_hash; $review = $review && $same_schema; echo '
'; echo '
' . esc_html($recovery ? __('Unsaved edits recovered', 'customfields-com') : __('All changes saved', 'customfields-com')) . '
'; echo ''; if ($recovery) { self::recovery_panel($recovery); echo '

' . esc_html__('Your submitted values are shown below. They are not stored as a recovery draft. Keep this page open until you finish.', 'customfields-com') . '

'; if (!$same_schema) { echo '

' . esc_html__('Open the current field definitions in a new tab', 'customfields-com') . '

'; } if ($review) { echo '
' . esc_html__('Current saved values — compare with your edits below', 'customfields-com') . '
'; foreach ($groups as $group) { foreach ($group['fields'] as $field) { echo '
' . esc_html($field['label']) . '
' . esc_html(wp_json_encode($values[$field['name']] ?? $field['default'], JSON_PRETTY_PRINT)) . '
'; } } echo '

'; } elseif ($same_schema) { echo '

'; } } wp_nonce_field('customfields_pro_options'); echo ''; // Verify a complete form so max_input_vars truncation cannot erase trailing values. foreach ($groups as $group) { echo '

' . esc_html($group['title']) . '

'; foreach ($group['fields'] as $field) { $input_name = 'customfields_pro_values[' . $field['name'] . ']'; $id = 'cf-' . substr(md5($input_name), 0, 16); echo '
'; $value = $values[$field['name']] ?? $field['default']; if (array_key_exists($field['name'], $submitted)) { $value = $submitted[$field['name']]; } $valid = \CustomFields\Fields::sanitize($value, $field, array('has_stored_value' => array_key_exists($field['name'], $values), 'stored_value' => $values[$field['name']] ?? null)); if (isset($raw_recovery[$field['name']]) && is_string($raw_recovery[$field['name']])) { echo '

' . esc_html__('Correct this JSON value; the exact submitted text is retained.', 'customfields-com') . '

'; } elseif ($recovery && array_key_exists($field['name'], $submitted) && (is_wp_error($valid) || in_array($field['type'], array('group', 'repeater', 'flexible', 'clone'), true))) { echo '

' . esc_html__('This submitted value needs correction. Its exact JSON is preserved here because the field control cannot represent it safely.', 'customfields-com') . '

'; } else { \CustomFields\Fields::render($field, $recovery && !is_wp_error($valid) ? $valid : $value, $input_name); } if (!empty($field['instructions'])) { echo '

' . esc_html($field['instructions']) . '

'; } echo '
'; } do_action('customfields_editor_next', $group, 'options', 0); echo '
'; } echo ''; submit_button(__('Save site options', 'customfields-com')); echo '
'; } private static function option_error($code, $message, $status = 400, $field = '') { return new \WP_Error($code, $message, array('status' => $status, 'field' => $field)); } private static function options_input($request) { $input = array_key_exists('customfields_pro_values', $request) ? $request['customfields_pro_values'] : array(); if (array_key_exists('values_json', $request)) { if (!is_string($request['values_json']) || strlen($request['values_json']) > 1048576) { return self::option_error('options_size', __('This options form exceeds the 1 MB limit.', 'customfields-com')); } $input = json_decode($request['values_json'], true, 16); if (json_last_error() !== JSON_ERROR_NONE) { return self::option_error('options_json', __('The submitted values could not be read. Your work is still in this form.', 'customfields-com')); } } if (!is_array($input) || strlen(wp_json_encode($input)) > 1048576) { return self::option_error('options_size', __('Provide an options form of at most 1 MB.', 'customfields-com')); } $recovered = array_key_exists('customfields_pro_recovery_json', $request) ? $request['customfields_pro_recovery_json'] : array(); if (!is_array($recovered)) { return self::option_error('options_json', __('The recovered values are malformed.', 'customfields-com')); } foreach ($recovered as $name => $json) { if (!is_string($json) || strlen($json) > 1048576) { return self::option_error('options_json', __('A recovered value is too large.', 'customfields-com')); } $value = json_decode($json, true, 16); if (json_last_error() !== JSON_ERROR_NONE) { return self::option_error('options_json', __('Correct the recovered value JSON before saving.', 'customfields-com'), 400, $name); } $input[$name] = $value; } $encoded = wp_json_encode($input); if (false === $encoded || strlen($encoded) > 1048576) { return self::option_error('options_size', __('The recovered form exceeds the 1 MB limit.', 'customfields-com')); } return $input; } /** Same validation and optimistic write for native and AJAX transports. */ private static function options_request($request) { global $wpdb; $slug = $request['options_page_slug'] ?? ''; if (!is_user_logged_in() || !is_string($slug) || !preg_match('/^[a-z][a-z0-9_-]{0,39}$/D', $slug) || !self::groups($slug)) { return self::option_error('options_forbidden', __('You cannot edit this options page.', 'customfields-com'), 403); } $table = $wpdb->get_row($wpdb->prepare('SHOW TABLE STATUS LIKE %s', $wpdb->esc_like($wpdb->options)), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect/provision the actual database schema; cached application metadata cannot establish storage-engine or column guarantees. if (!$table || strtolower($table['Engine'] ?? '') !== 'innodb') { return self::option_error('options_database', __('Options editing requires a transactional options table.', 'customfields-com'), 500); } if (false === $wpdb->query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE') || false === $wpdb->query('START TRANSACTION')) { return self::option_error('options_database', __('A protected save could not be started.', 'customfields-com'), 500); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. try { foreach (array('customfields_groups', 'customfields_pro_values') as $name) { if (false === $wpdb->query($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name=%s FOR UPDATE", $name))) { throw new \RuntimeException(__('The options could not be locked. Nothing was saved.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Read or lock authoritative rows for the transaction; an object-cache result cannot provide database locking or conflict detection. wp_cache_delete($name, 'options'); } wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); // Resolve current group capabilities and schema only after the schema row is locked. $result = self::options_request_validated($request); if (is_wp_error($result)) { $wpdb->query('ROLLBACK'); wp_cache_delete('customfields_pro_values', 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); return $result; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. if (false === $wpdb->query('COMMIT')) { throw new \RuntimeException(__('The options save could not be committed. Reload and verify before retrying.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. wp_cache_delete('customfields_pro_values', 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); return $result; } catch (\Throwable $error) { $wpdb->query('ROLLBACK'); wp_cache_delete('customfields_pro_values', 'options'); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached. return self::option_error('options_write', $error->getMessage(), 500); } } private static function options_request_validated($request) { $slug = $request['options_page_slug'] ?? ''; if (!is_string($slug) || !preg_match('/^[a-z][a-z0-9_-]{0,39}$/D', $slug)) { return self::option_error('options_forbidden', __('This options page is unavailable.', 'customfields-com'), 403); } $groups = self::groups($slug); if (!$groups) { return self::option_error('options_forbidden', __('You cannot edit this options page.', 'customfields-com'), 403); } $schema = hash('sha256', wp_json_encode($groups)); if (!is_string($request['customfields_pro_schema'] ?? null) || !hash_equals($schema, $request['customfields_pro_schema'])) { return self::option_error('options_schema', __('The field definitions changed. Keep this form open and review the updated page in a new tab before transferring your edits.', 'customfields-com'), 409); } if (($request['customfields_pro_complete'] ?? '') !== '1') { return self::option_error('options_incomplete', __('The form was incomplete. Nothing was saved. Check the PHP input limit.', 'customfields-com')); } if (isset($request['options_rebase']) && ($request['options_confirm_review'] ?? '') !== '1') { return self::option_error('options_review', __('Confirm that you reviewed the current saved values before saving your edits.', 'customfields-com')); } $submitted = self::options_input($request); if (is_wp_error($submitted)) { return $submitted; } $values = get_option('customfields_pro_values', array()); if (!is_array($values)) { return self::option_error('options_storage', __('The saved options cannot be read safely.', 'customfields-com')); } if (!is_string($request['customfields_pro_values_hash'] ?? null) || !hash_equals(self::values_hash($values, $groups), $request['customfields_pro_values_hash'])) { return self::option_error('options_conflict', __('Someone saved this page after you opened it. Your edits are still here. Review the current values before choosing what to save.', 'customfields-com'), 409); } $updates = array(); foreach ($groups as $group) { $clean_group = \CustomFields\Conditions::resolve_values($group['fields'], $submitted, $values, static function($field) use ($submitted, $values) { if (array_key_exists($field['name'], $values)) { $stored = \CustomFields\Fields::storage_error($values[$field['name']], $field); if (is_wp_error($stored)) { return $stored; } } if (!array_key_exists($field['name'], $submitted)) { return new \WP_Error('options_incomplete', __('This field is missing. Nothing was saved.', 'customfields-com')); } return \CustomFields\Fields::sanitize($submitted[$field['name']], $field, array('has_stored_value' => array_key_exists($field['name'], $values), 'stored_value' => $values[$field['name']] ?? null)); }); if (is_wp_error($clean_group)) { return self::option_error('options_invalid', implode(' ', $clean_group->get_error_messages()), 400, $clean_group->get_error_code()); } $updates = array_replace($updates, $clean_group); } $saved = self::write_values($values, $updates, $groups); if (is_wp_error($saved)) { return self::option_error($saved->get_error_code(), $saved->get_error_message(), in_array($saved->get_error_code(), array('options_conflict', 'options_busy'), true) ? 409 : 500); } // Bind the next request to this write, not to another writer's later result. return array('schema_hash' => $schema, 'values_hash' => self::values_hash(array_replace($values, $updates), $groups), 'contexts' => self::options_contexts($groups, $updates), 'condition_values' => \CustomFields\Conditions::option_values($groups, $updates), 'field_hashes' => array_map(array('CustomFields\\Local\\Fields', 'fingerprint'), $updates), 'message' => __('Site options saved.', 'customfields-com')); } /** JSON lists preserve PHP iteration order; native baselines remain unchanged. */ public static function editor_value($field, $value) { if (!is_array($value) || !in_array($field['type'], array('repeater', 'group', 'flexible', 'clone'), true)) { return $value; } $multiple = in_array($field['type'], array('repeater', 'flexible'), true); $rows = $multiple ? array_values($value) : array($value); $layouts = array_column($field['layouts'] ?? array(), null, 'name'); foreach ($rows as &$row) { if (!is_array($row)) { continue; } $children = $field['type'] === 'flexible' ? ($layouts[$row['__layout'] ?? '']['sub_fields'] ?? array()) : ($field['sub_fields'] ?? array()); foreach ($children as $child) { if (array_key_exists($child['name'], $row)) { $row[$child['name']] = self::editor_value($child, $row[$child['name']]); } } } unset($row); return $multiple ? $rows : $rows[0]; } public static function value_contexts($fields, $values, $base) { $contexts = array(); $visit = function($field, $value, $prefix) use (&$visit, &$contexts) { if ($field['type'] === 'relationship' && class_exists('CustomFields\\Relationships')) { $contexts[$prefix] = array('fingerprint' => \CustomFields\Relationships::hash($value), 'stored' => $value); return; } if (!in_array($field['type'], array('repeater', 'group', 'flexible', 'clone'), true) || !is_array($value)) { return; } $contexts[$prefix] = array('fingerprint' => Fields::fingerprint($value), 'stored' => $value); if (in_array($field['type'], array('repeater', 'flexible'), true)) { $contexts[$prefix]['row_sources'] = array_keys($value); } $multiple = in_array($field['type'], array('repeater', 'flexible'), true); $rows = $multiple ? array_values($value) : array($value); $layouts = array_column($field['layouts'] ?? array(), null, 'name'); foreach ($rows as $index => $row) { $children = $field['type'] === 'flexible' ? ($layouts[$row['__layout'] ?? '']['sub_fields'] ?? array()) : ($field['sub_fields'] ?? array()); foreach ($children as $child) { if (array_key_exists($child['name'], $row)) { $visit($child, $row[$child['name']], $prefix . ($multiple ? '[' . $index . ']' : '') . '[' . $child['name'] . ']'); } } } }; foreach ($fields as $field) { if (array_key_exists($field['name'], $values)) { $visit($field, $values[$field['name']], $base . '[' . $field['name'] . ']'); } } return $contexts; } private static function options_contexts($groups, $updates) { $contexts = array(); foreach ($groups as $group) { $contexts = array_merge($contexts, self::value_contexts($group['fields'], $updates, 'customfields_pro_values')); } return $contexts; } public static function ajax_options() { if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce'])) { wp_send_json_error(array('code' => 'options_nonce', 'message' => __('Your session expired. Keep your edits here and sign in again in another tab.', 'customfields-com')), 403); } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'customfields_pro_options')) { wp_send_json_error(array('code' => 'options_nonce', 'message' => __('Your session expired. Keep your edits here and sign in again in another tab.', 'customfields-com')), 403); } $request = self::options_http_request(); $slug = $request['options_page_slug'] ?? ''; if (!is_string($slug) || !preg_match('/^[a-z][a-z0-9_-]{0,39}$/D', $slug) || !self::groups($slug)) { wp_send_json_error(array('code' => 'options_forbidden', 'message' => __('You cannot edit this options page.', 'customfields-com')), 403); } if (($request['intent'] ?? '') === 'review') { $groups = self::groups($slug); $values = get_option('customfields_pro_values', array()); if (!is_array($values)) { wp_send_json_error(array('code' => 'options_storage', 'message' => __('The saved options cannot be read safely.', 'customfields-com')), 500); } $visible = array(); foreach ($groups as $group) { foreach ($group['fields'] as $field) { $visible[] = array('name' => $field['name'], 'label' => $field['label'], 'type' => $field['type'], 'fingerprint' => Fields::fingerprint($values[$field['name']] ?? $field['default']), 'value' => $values[$field['name']] ?? $field['default'], 'editor_value' => self::editor_value($field, $values[$field['name']] ?? $field['default'])); } } wp_send_json_success(array('schema_hash' => hash('sha256', wp_json_encode($groups)), 'values_hash' => self::values_hash($values, $groups), 'fields' => $visible, 'contexts' => self::options_contexts($groups, $values))); } $result = self::options_request($request); if (is_wp_error($result)) { $data = $result->get_error_data(); wp_send_json_error(array('code' => $result->get_error_code(), 'message' => $result->get_error_message(), 'field' => $data['field'] ?? ''), (int) ($data['status'] ?? 400)); } wp_send_json_success($result); } public static function save_options() { if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce'])) { wp_die(esc_html__('Your session expired. Nothing was saved.', 'customfields-com'), '', array('response' => 403)); } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'customfields_pro_options')) { wp_die(esc_html__('Your session expired. Nothing was saved.', 'customfields-com'), '', array('response' => 403)); } $request = self::options_http_request(); $review = ($request['options_intent'] ?? '') === 'review'; $result = $review ? self::option_error('options_review', __('Review the saved values below. Your edits have not been saved.', 'customfields-com')) : self::options_request($request); if (is_wp_error($result)) { // Render within this authenticated response. No invalid values are stored in options, transients or browser storage. nocache_headers(); $GLOBALS['title'] = __('Review site options', 'customfields-com'); require_once ABSPATH . 'wp-admin/admin-header.php'; self::options_page($request, $result, $review); require_once ABSPATH . 'wp-admin/admin-footer.php'; exit; } self::notice('success', $result['message']); self::redirect(self::menu_slug(self::selected_page())); } public static function analyze() { self::authorize(); if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce'])) { wp_die(esc_html__('Your session expired. Run the analysis again.', 'customfields-com'), '', array('response' => 403)); } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'customfields_pro_analyze')) { wp_die(esc_html__('Your session expired. Run the analysis again.', 'customfields-com'), '', array('response' => 403)); } $json = isset($_POST['acf_json']) && is_string($_POST['acf_json']) ? wp_unslash($_POST['acf_json']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The capability and nonce were verified above. Migrator::analyze bounds/parses the JSON, then Schema::validate_groups sanitizes and validates every candidate. Text sanitation would corrupt JSON and rich defaults. $report = Migrator::analyze($json, customfields_get_groups()); $token = wp_generate_password(32, false, false); set_transient('customfields_pro_import_' . get_current_user_id(), array('token' => $token, 'report' => $report, 'schema_hash' => hash('sha256', wp_json_encode(get_option('customfields_groups', array())))), 15 * MINUTE_IN_SECONDS); self::redirect('customfields-pro-import'); } public static function import() { self::authorize(); if (!isset($_POST['_wpnonce']) || !is_string($_POST['_wpnonce'])) { wp_die(esc_html__('Your session expired. Analyze the export again.', 'customfields-com'), '', array('response' => 403)); } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'customfields_pro_import')) { wp_die(esc_html__('Your session expired. Analyze the export again.', 'customfields-com'), '', array('response' => 403)); } $key = 'customfields_pro_import_' . get_current_user_id(); $preview = get_transient($key); $token = isset($_POST['preview_token']) && is_string($_POST['preview_token']) ? sanitize_text_field(wp_unslash($_POST['preview_token'])) : ''; if (!is_array($preview) || !hash_equals($preview['token'], $token) || !empty($preview['report']['errors']) || empty($preview['report']['candidates']) || ($_POST['acknowledge'] ?? '') !== '1') { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The capability and nonce were verified above. Exact comparison to the expected completion/confirmation or form-identity value; invalid strings and arrays are rejected, not rendered or persisted. self::notice('error', __('Run the schema analysis and confirm its limitations before importing. Previews expire after 15 minutes.', 'customfields-com')); self::redirect('customfields-pro-import'); } if (!hash_equals($preview['schema_hash'], hash('sha256', wp_json_encode(get_option('customfields_groups', array()))))) { self::notice('error', __('Field groups changed since this preview. Analyze the export again.', 'customfields-com')); self::redirect('customfields-pro-import'); } $groups = \CustomFields\Schema::validate_groups(array_merge(customfields_get_groups(), $preview['report']['candidates'])); if (is_wp_error($groups)) { self::notice('error', $groups->get_error_message()); self::redirect('customfields-pro-import'); } $before = customfields_get_groups(); update_option('customfields_groups', $groups, false); do_action('customfields_schema_saved', $groups, $before); delete_transient($key); $imported_count = count($preview['report']['candidates']); self::notice('success', sprintf(/* translators: %s: Imported group count. */ _n('%s field group schema imported. Existing content was not migrated or changed.', '%s field group schemas imported. Existing content was not migrated or changed.', $imported_count, 'customfields-com'), number_format_i18n($imported_count))); self::redirect('customfields-pro-import'); } public static function import_page() { self::authorize(); echo '

' . esc_html__('Import field definitions', 'customfields-com') . '

' . esc_html__('Analyze an ACF JSON export before making changes. This imports supported field definitions only. It does not migrate existing field values, templates, or PHP API calls.', 'customfields-com') . '

'; self::show_notice(); $preview = get_transient('customfields_pro_import_' . get_current_user_id()); if (is_array($preview)) { $report = $preview['report']; echo '

' . esc_html__('Analysis report', 'customfields-com') . '

'; self::messages($report['errors'], 'cfp-errors'); self::messages($report['warnings'], 'cfp-warnings'); foreach ($report['groups'] as $group) { echo '

' . esc_html(is_string($group['title'] ?? null) ? sanitize_text_field($group['title']) : '') . ' — ' . esc_html($group['errors'] ? __('Blocked', 'customfields-com') : __('Supported schema', 'customfields-com')) . '

'; self::messages($group['errors'], 'cfp-errors'); self::messages($group['warnings'], 'cfp-warnings'); echo '

' . esc_html(sprintf(/* translators: %s: Analyzed field count. */ _n('%s field definition analyzed.', '%s field definitions analyzed.', $group['field_count'], 'customfields-com'), number_format_i18n($group['field_count']))) . '

'; } if (!$report['errors'] && $report['candidates']) { echo '
'; wp_nonce_field('customfields_pro_import'); echo '

'; submit_button(__('Import supported schemas', 'customfields-com')); echo '
'; } echo '
'; } echo '
'; wp_nonce_field('customfields_pro_analyze'); echo '

'; submit_button(__('Analyze without importing', 'customfields-com')); echo '
'; } private static function messages($messages, $class) { if (!is_array($messages) || !$messages) { return; } echo ''; } }