409)); } public static function field_schema($field, $raw, $depth) { if (is_wp_error($field) || !isset($raw['source_mode'])) { return $field; } if (($field['type'] ?? '') !== 'clone' || !in_array($raw['source_mode'], array('snapshot', 'linked'), true)) { return self::error(__('Definition mode must be snapshot or linked on a reusable group.', 'customfields-com')); } $field['source_mode'] = $raw['source_mode']; return $field; } public static function validate($groups) { if (is_wp_error($groups)) { return $groups; } $expanded = self::expand($groups, false); return is_wp_error($expanded) ? $expanded : $groups; } /** Always resolve linked children from the central source; cached children are an approval record. */ public static function resolve($groups) { if ($groups === self::$resolved_input) { return self::$resolved_output; } $resolved = self::expand($groups, false); if (!is_wp_error($resolved)) { self::$resolved_input = $groups; self::$resolved_output = $resolved; return $resolved; } // A manually corrupted registry is visible for recovery but locked for ordinary content writes. $raw = $groups; // Lock at each top-level boundary; do not recursively traverse corrupt definitions again. if (!is_array($groups)) { return array(); } foreach ($groups as &$group) { if (!is_array($group) || !is_array($group['fields'] ?? null)) { continue; } foreach ($group['fields'] as &$field) { if (is_array($field)) { $field['_linked_conflict'] = $resolved->get_error_message(); } } unset($field); } unset($group); self::$resolved_input = $raw; self::$resolved_output = $groups; return $groups; } public static function storage_error($error, $value, $field) { return !empty($field['_linked_conflict']) ? self::error($field['_linked_conflict']) : $error; } public static function sanitize($value, $field, $raw, $context) { return !empty($field['_linked_conflict']) ? self::error($field['_linked_conflict']) : $value; } private static function expand($groups, $refresh) { if (!is_array($groups) || count($groups) > Schema::MAX_GROUPS) { return self::error(__('The linked registry is invalid.', 'customfields-com')); } $map = array(); foreach ($groups as $group) { if (!is_array($group) || !is_string($group['key'] ?? null) || isset($map[$group['key']]) || !is_array($group['fields'] ?? null)) { return self::error(__('A linked source group is invalid.', 'customfields-com')); } $map[$group['key']] = $group; } $walk = function($fields, $path, $depth, &$nodes) use (&$walk, $map, $refresh) { if (!is_array($fields) || $depth > Local\Fields::MAX_DEPTH) { return self::error(__('The linked children exceed the supported nesting depth or have an invalid shape.', 'customfields-com')); } $result = array(); foreach ($fields as $field) { if (!is_array($field) || ++$nodes > Local\Fields::MAX_SCHEMA_FIELDS) { return self::error(__('A linked group exceeds 200 resolved schema fields.', 'customfields-com')); } $type = $field['type'] ?? ''; if (in_array($type, array('group', 'clone', 'repeater', 'flexible'), true) && $depth >= Local\Fields::MAX_DEPTH) { return self::error(__('Linked definitions may use at most three container levels.', 'customfields-com')); } if ($type === 'clone' && ($field['source_mode'] ?? '') === 'linked') { $key = $field['source_group'] ?? ''; if (!isset($map[$key]) || in_array($key, $path, true)) { return self::error(__('A linked definition has a missing source or a dependency cycle. Restore its source or unlink it explicitly.', 'customfields-com')); } $children = $walk($map[$key]['fields'], array_merge($path, array($key)), $depth + 1, $nodes); if (is_wp_error($children)) { return $children; } $hash = Local\Fields::fingerprint($children); if (!$refresh && (!is_string($field['source_hash'] ?? null) || !hash_equals($hash, $field['source_hash']) || Local\Fields::fingerprint($field['sub_fields'] ?? array()) !== $hash)) { return self::error(__('A linked source changed. Use “Review linked updates” to check every downstream field and saved value before applying it.', 'customfields-com')); } $field['sub_fields'] = $children; if ($refresh) { $field['source_hash'] = $hash; $field['source_title'] = $map[$key]['title']; } } else { if (isset($field['sub_fields'])) { $field['sub_fields'] = $walk($field['sub_fields'], $path, $depth + 1, $nodes); if (is_wp_error($field['sub_fields'])) { return $field['sub_fields']; } } if (isset($field['layouts']) && !is_array($field['layouts'])) { return self::error(__('The linked layouts have an invalid shape.', 'customfields-com')); } foreach ($field['layouts'] ?? array() as $i => $layout) { $children = $walk(is_array($layout) ? ($layout['sub_fields'] ?? null) : null, $path, $depth + 1, $nodes); if (is_wp_error($children)) { return $children; } $field['layouts'][$i]['sub_fields'] = $children; } } $result[] = $field; } return $result; }; $result = array(); foreach ($groups as $group) { $nodes = 0; $fields = $walk($group['fields'], array($group['key']), 0, $nodes); if (is_wp_error($fields)) { return $fields; } $group['fields'] = $fields; $result[] = $group; } return $result; } /** Preview a complete proposed registry. Only this path refreshes every linked approval together. */ public static function preview($groups) { if (!current_user_can('manage_options')) { return self::error(__('Administrator access is required.', 'customfields-com')); } $expanded = self::expand($groups, true); if (is_wp_error($expanded)) { return $expanded; } $encoded = wp_json_encode($expanded); if (false === $encoded || strlen($encoded) > Schema::MAX_BYTES) { return self::error(__('The resolved schema exceeds 1 MB.', 'customfields-com')); } return Local\Workflows::preview_schema($expanded, array('kind' => 'linked-definitions')); } public static function apply($id) { if (!current_user_can('manage_options')) { return self::error(__('Administrator access is required.', 'customfields-com')); } // The shared preview is user/session-bound; confirm its context before invoking the common transactional restore. // Workflow implementations own the private key. A context guard binds the transaction itself. $guard = static function($review) { if (($review['context']['kind'] ?? '') !== 'linked-definitions') { throw new \RuntimeException(__('This review is not a linked-definition update.', 'customfields-com')); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. }; add_action('customfields_schema_review_committing', $guard, 1); try { return Local\Workflows::restore($id); } finally { remove_action('customfields_schema_review_committing', $guard, 1); } } public static function assets($hook) { if ($hook !== 'toplevel_page_customfields') { return; } wp_enqueue_script('customfields-linked', CUSTOMFIELDS_LOCAL_URL . 'linked.js', array('customfields-builder', 'wp-i18n'), CUSTOMFIELDS_VERSION, true); wp_set_script_translations('customfields-linked', 'customfields-com', CUSTOMFIELDS_PATH . 'languages'); wp_localize_script('customfields-linked', 'CustomFieldsLinked', array('ajax' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('customfields_linked'))); } private static function require_administrator() { if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Administrator access is required.', 'customfields-com')), 403); } } /** Only a complete, schema-sanitized group may enter a downstream review. */ private static function sanitize_group_json($json) { if (!is_string($json) || strlen($json) > Schema::MAX_BYTES) { return self::error(__('The submitted group exceeds the schema limit.', 'customfields-com')); } $raw = json_decode($json, true, 24); if (JSON_ERROR_NONE !== json_last_error() || !is_array($raw) || ($raw['complete'] ?? '') !== '1') { return self::error(__('The group is incomplete.', 'customfields-com')); } // This validates every typed setting and sanitizes labels, defaults and nested fields. return Schema::validate_group($raw); } public static function ajax_preview() { self::require_administrator(); $nonce = isset($_POST['nonce']) && is_string($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (!wp_verify_nonce($nonce, 'customfields_linked')) { wp_send_json_error(array('message' => __('Your session expired. Reload this page before continuing.', 'customfields-com')), 403); } $schema_hash = isset($_POST['schema_hash']) && is_string($_POST['schema_hash']) ? filter_var(wp_unslash($_POST['schema_hash']), FILTER_VALIDATE_REGEXP, array('options' => array('regexp' => '/^[a-f0-9]{64}$/D'))) : false; if (!is_string($schema_hash) || !hash_equals(Admin::fingerprint(), $schema_hash)) { wp_send_json_error(array('message' => __('The registry changed. Keep this editor open and review a fresh one before applying.', 'customfields-com')), 409); } $group = self::sanitize_group_json(isset($_POST['group_json']) && is_string($_POST['group_json']) ? wp_unslash($_POST['group_json']) : null); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize_group_json bounds and decodes the transport, then Schema::validate_group sanitizes every supported field before any preview is stored. if (is_wp_error($group)) { wp_send_json_error(array('message' => $group->get_error_message()), 400); } $groups = get_option('customfields_groups', array()); if (!is_array($groups)) { wp_send_json_error(array('message' => __('The linked registry is invalid.', 'customfields-com')), 409); } $found = false; foreach ($groups as &$entry) { if (is_array($entry) && ($entry['key'] ?? null) === $group['key']) { $entry = $group; $found = true; } } unset($entry); if (!$found) { $groups[] = $group; } $preview = self::preview($groups); if (is_wp_error($preview)) { wp_send_json_error(array('message' => $preview->get_error_message()), 409); } wp_send_json_success(array('id' => $preview['id'], 'diff' => $preview['diff'], 'values' => $preview['scan']['values'], 'group_key' => $group['key'])); } public static function ajax_apply() { self::require_administrator(); $nonce = isset($_POST['nonce']) && is_string($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''; if (!wp_verify_nonce($nonce, 'customfields_linked')) { wp_send_json_error(array('message' => __('Your session expired. Reload this page before continuing.', 'customfields-com')), 403); } $id = isset($_POST['preview_id']) && is_string($_POST['preview_id']) ? filter_var(wp_unslash($_POST['preview_id']), FILTER_VALIDATE_REGEXP, array('options' => array('regexp' => '/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/D'))) : false; $confirm = isset($_POST['confirm']) && is_string($_POST['confirm']) ? filter_var(wp_unslash($_POST['confirm']), FILTER_VALIDATE_REGEXP, array('options' => array('regexp' => '/^1$/D'))) : false; if ('1' !== $confirm) { wp_send_json_error(array('message' => __('Confirm the downstream review before applying.', 'customfields-com')), 400); } $result = self::apply($id); if (is_wp_error($result)) { wp_send_json_error(array('message' => $result->get_error_message()), 409); } $key = isset($_POST['group_key']) && is_string($_POST['group_key']) ? sanitize_text_field(wp_unslash($_POST['group_key'])) : ''; foreach (customfields_get_groups() as $group) { if ($group['key'] === $key) { wp_send_json_success(array('group' => $group, 'schema_hash' => Admin::fingerprint(), 'message' => __('Linked definitions and their compatible downstream uses were saved.', 'customfields-com'))); } } wp_send_json_error(array('message' => __('The review was applied, but this group is no longer available. Reload the registry.', 'customfields-com')), 409); } }