$dir, 'hash' => null, 'groups' => null); } $json = Portability::read_local($path); if (is_wp_error($json)) { return $json; } $groups = Schema::decode($json); if (is_wp_error($groups)) { return $groups; } return array('directory' => $dir, 'hash' => hash('sha256', $json), 'groups' => $groups); } public static function status() { $file = self::read(); if (is_wp_error($file)) { return $file; } $db = self::hash(customfields_get_groups()); $state = get_option(self::STATE, array()); $same = $file['groups'] !== null && $db === self::hash($file['groups']); $tracked = ($state['directory'] ?? '') === $file['directory']; $file_changed = !$tracked || $file['hash'] !== ($state['file_hash'] ?? null); $db_changed = !$tracked || $db !== ($state['schema_hash'] ?? null); $file['schema_hash'] = $db; $file['status'] = $same ? 'synchronized' : ($file['hash'] === null ? 'missing' : ($file_changed && $db_changed ? 'conflict' : ($file_changed ? 'file_changed' : 'database_changed'))); return $file; } /** Serialize site imports without creating a lock file in the source directory. */ private static function lock() { global $wpdb; $name = 'customfields_sync_' . substr(hash('sha256', DB_NAME . ':' . $wpdb->options), 0, 40); $locked = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $name)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Connection-scoped import lock; WordPress has no advisory-lock API and the result must not be cached. return (string) $locked === '1' ? $name : self::error(__('Another definition import is running, or the database cannot acquire its lock. Try again later.', 'customfields-com')); } private static function unlock($lock) { global $wpdb; $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Release the connection-scoped import lock acquired above. } private static function remember($file, $groups) { $state = array('directory' => $file['directory'], 'file_hash' => $file['hash'], 'schema_hash' => self::hash($groups), 'time' => time()); update_option(self::STATE, $state, false); wp_cache_delete(self::STATE, 'options'); if (get_option(self::STATE) !== $state) { return self::error(__('Could not save the import baseline. Review again before another import.', 'customfields-com')); } return true; } /** Compatibility callbacks: committed schema saves remain in the database only. */ public static function queue($groups, $before = array()) {} public static function flush() {} public static function preview($direction) { if (!current_user_can('manage_options')) { return self::error(__('Administrator access required.', 'customfields-com')); } if ($direction !== 'import') { return self::error(__('File exports have been replaced by Download JSON. No server file was written.', 'customfields-com')); } $file = self::status(); if (is_wp_error($file)) { return $file; } $current = Schema::validate_groups(customfields_get_groups()); if (is_wp_error($current)) { return $current; } if ($file['groups'] === null) { return self::error(__('The configured source has no schema file. Use the field builder to review a JSON import.', 'customfields-com')); } $groups = $file['groups']; $review = Local\Workflows::preview_schema($groups, array('kind' => 'file-sync')); if (is_wp_error($review)) { return $review; } $plan = array('id' => wp_generate_uuid4(), 'direction' => $direction, 'file' => $file, 'groups' => $groups, 'review' => $review); set_transient(self::key(), $plan, 15 * MINUTE_IN_SECONDS); return $plan; } public static function apply($id) { if (!current_user_can('manage_options')) { return self::error(__('Administrator access required.', 'customfields-com')); } $plan = get_transient(self::key()); if (!is_array($plan) || ($plan['direction'] ?? '') !== 'import' || !is_string($id) || !isset($plan['id']) || !is_string($plan['id']) || !hash_equals($plan['id'], $id)) { return self::error(__('The import review expired or is no longer supported. Review again.', 'customfields-com')); } $lock = self::lock(); if (is_wp_error($lock)) { return $lock; } try { $file = self::status(); if (is_wp_error($file)) { return $file; } foreach (array('directory', 'hash', 'schema_hash') as $key) { if ($file[$key] !== $plan['file'][$key]) { return self::error(__('The database or file changed after review. Review the differences again.', 'customfields-com')); } } self::$applying = $plan; $result = Local\Workflows::restore($plan['review']['id']); if (!is_wp_error($result)) { delete_transient(self::key()); } return $result; } finally { self::$applying = false; wp_cache_delete(self::STATE, 'options'); self::unlock($lock); } } public static function committing($preview, $groups) { if (($preview['context']['kind'] ?? '') !== 'file-sync') { return; } if (!self::$applying || self::$applying['review']['id'] !== $preview['id']) { throw new \RuntimeException(__('Apply file changes from the Definition files review.', '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. $fresh = self::read(); if (is_wp_error($fresh) || $fresh['hash'] !== self::$applying['file']['hash'] || $fresh['directory'] !== self::$applying['file']['directory']) { throw new \RuntimeException(__('The JSON file changed while applying the review.', '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. $result = self::remember($fresh, $groups); if (is_wp_error($result)) { throw new \RuntimeException($result->get_error_message()); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception transport, not an HTML output sink; public handlers escape the resulting error at rendering time. } public static function menu() { add_submenu_page('customfields', __('Definition files', 'customfields-com'), __('Definition files', 'customfields-com'), 'manage_options', 'customfields-sync', array(__CLASS__, 'page')); } public static function handle() { if (!current_user_can('manage_options')) { wp_die('Administrator access required.', '', array('response' => 403)); } $nonce = isset($_POST['_wpnonce']) && is_string($_POST['_wpnonce']) ? sanitize_text_field(wp_unslash($_POST['_wpnonce'])) : ''; if (!wp_verify_nonce($nonce, 'customfields_sync')) { wp_die(esc_html__('Your session expired. Reload the definition files page.', 'customfields-com'), '', array('response' => 403)); } $operation = isset($_POST['operation']) && is_string($_POST['operation']) ? sanitize_text_field(wp_unslash($_POST['operation'])) : ''; $id = isset($_POST['review']) && is_string($_POST['review']) ? sanitize_text_field(wp_unslash($_POST['review'])) : ''; $result = $operation === 'apply' ? self::apply($id) : self::preview($operation); if (is_wp_error($result)) { wp_die(esc_html($result->get_error_message()), '', array('response' => 409, 'back_link' => true)); } wp_safe_redirect(admin_url('admin.php?page=customfields-sync')); exit; } private static function form($operation, $label, $id = '') { echo '
'; } public static function page() { if (!current_user_can('manage_options')) { return; } echo '' . esc_html__('Download the current definitions as JSON or review an import in the field builder. Definitions stay in the database; no schema, temporary file or lock file is written to a server directory.', 'customfields-com') . '
'; echo '' . esc_html__('Download JSON', 'customfields-com') . ' ' . esc_html__('Review a JSON import', 'customfields-com') . '
'; if (!defined('CUSTOMFIELDS_JSON_DIR')) { echo '' . esc_html__('The existing CUSTOMFIELDS_JSON_DIR setting is used only to read customfields-schema.json. Review its changes before importing. Imports replace definitions; saved content remains in place. Download JSON and manage any repository copy separately.', 'customfields-com') . '
'; $status = self::status(); if (is_wp_error($status)) { echo '' . esc_html($status->get_error_message()) . '
' . esc_html($labels[$status['status']]) . ' — ' . esc_html($status['directory']) . '
' . esc_html__('Applying uses exactly this review. Any intervening schema, file or checked content changes block the import.', 'customfields-com') . '
' . esc_html(wp_json_encode($plan['review']['diff'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . ''; self::form('apply', __('Apply reviewed import', 'customfields-com'), $plan['id']); } echo ''; } }