true, 'edit_pages' => true, 'edit_others_pages' => true, 'edit_published_pages' => true, 'publish_pages' => true, 'zeopilot_connector_access' => true, ); } function zeopilot_connector_service_user($user) { return $user instanceof WP_User && (int) get_user_meta($user->ID, 'zeopilot_connector_site_id', true) === get_current_blog_id(); } function zeopilot_connector_limited_user($user) { if (!zeopilot_connector_service_user($user) || $user->roles !== array('zeopilot_connector') || (is_multisite() && is_super_admin($user->ID))) { return false; } $allowed = zeopilot_connector_capabilities(); foreach ($user->allcaps as $cap => $granted) { if ($granted && $cap !== 'zeopilot_connector' && empty($allowed[$cap])) { return false; } } return true; } function zeopilot_connector_register_role() { $role = get_role('zeopilot_connector'); if (!$role) { add_role('zeopilot_connector', 'Zeopilot content connector', zeopilot_connector_capabilities()); } } /** Revoke only this plugin's credential; never other applications or a personal password. */ function zeopilot_connector_revoke() { $state = get_option('website_autopilot_connector', array()); if (is_array($state) && !empty($state['user_id']) && !empty($state['uuid']) && class_exists('WP_Application_Passwords')) { $item = WP_Application_Passwords::get_user_application_password((int) $state['user_id'], $state['uuid']); if (is_array($item) && ($item['app_id'] ?? '') === '0d870486-d7be-4c2b-90ef-9edce9e35d31') { $deleted = WP_Application_Passwords::delete_application_password((int) $state['user_id'], $state['uuid']); if (is_wp_error($deleted) || !$deleted) { return false; } } } $service_id = (int) get_option('zeopilot_connector_service_user_id', 0); $service = $service_id ? get_userdata($service_id) : false; if (zeopilot_connector_service_user($service)) { if (class_exists('WP_Application_Passwords')) { WP_Application_Passwords::delete_all_application_passwords($service->ID); } // Keep author attribution for existing pages, but leave no usable authority. $service->set_role(''); if (class_exists('WP_Session_Tokens')) { WP_Session_Tokens::get_instance($service->ID)->destroy_all(); } } if (is_array($state) && !empty($state['user_id'])) { // Cleanup the pre-0.3.2 one-time transient; no new plaintext transient is stored. delete_transient('wap_connector_credential_' . (int) $state['user_id']); } delete_option('website_autopilot_connector'); return true; } function zeopilot_connector_migrate_legacy() { $state = get_option('website_autopilot_connector', array()); if (is_array($state) && !empty($state['uuid']) && ($state['permission_profile'] ?? '') !== 'page_content_only') { if (zeopilot_connector_revoke()) { update_option('zeopilot_connector_reconnect_required', true, false); } } } add_action('plugins_loaded', 'zeopilot_connector_migrate_legacy', 1); function zeopilot_connector_create_credentials($owner_id) { if (!current_user_can('manage_options') || get_current_user_id() !== (int) $owner_id || !class_exists('WP_Application_Passwords')) { return new WP_Error('zeopilot_connection_denied', 'A local administrator must create the connection.'); } zeopilot_connector_register_role(); $user_id = (int) get_option('zeopilot_connector_service_user_id', 0); $user = $user_id ? get_userdata($user_id) : false; if ($user && (!zeopilot_connector_service_user($user) || ($user->roles !== array() && !zeopilot_connector_limited_user($user)))) { return new WP_Error('zeopilot_role_changed', 'The dedicated connector account was changed. Review its role before reconnecting.'); } if (!zeopilot_connector_revoke()) { return new WP_Error('zeopilot_revoke_failed', 'The previous connection could not be revoked.'); } if (!$user) { $user_id = wp_insert_user(array( 'user_login' => 'zeopilot_' . strtolower(wp_generate_password(20, false, false)), 'user_pass' => wp_generate_password(64, true, true), 'display_name' => 'Zeopilot content connector', 'role' => 'zeopilot_connector', )); if (is_wp_error($user_id)) { return $user_id; } update_user_meta($user_id, 'zeopilot_connector_site_id', get_current_blog_id()); update_option('zeopilot_connector_service_user_id', (int) $user_id, false); $user = get_userdata($user_id); } else { $user->set_role('zeopilot_connector'); $user = get_userdata($user_id); } if (!zeopilot_connector_limited_user($user)) { return new WP_Error('zeopilot_role_changed', 'The connector role must contain only the documented content capabilities.'); } $result = WP_Application_Passwords::create_new_application_password($user->ID, array( 'name' => 'Zeopilot content connector', 'app_id' => '0d870486-d7be-4c2b-90ef-9edce9e35d31', )); if (is_wp_error($result)) { return $result; } list($password, $item) = $result; update_option('website_autopilot_connector', array( 'user_id' => (int) $user->ID, 'uuid' => sanitize_text_field($item['uuid']), 'connected_at' => gmdate('c'), 'status' => 'credential_created', 'permission_profile' => 'page_content_only', ), false); delete_option('zeopilot_connector_reconnect_required'); return array('username' => $user->user_login, 'application_password' => $password, 'application_password_uuid' => $item['uuid'], 'site_url' => home_url('/')); } /** Each REST request must use the current credential of the unchanged, limited account. */ add_action('wp_authenticate_application_password_errors', function ($error, $user, $item) { if (!zeopilot_connector_service_user($user) && ($item['app_id'] ?? '') !== '0d870486-d7be-4c2b-90ef-9edce9e35d31') { return; } $state = get_option('website_autopilot_connector', array()); if (!defined('REST_REQUEST') || !REST_REQUEST || !zeopilot_connector_limited_user($user) || (int) ($state['user_id'] ?? 0) !== $user->ID || !hash_equals((string) ($state['uuid'] ?? ''), (string) ($item['uuid'] ?? ''))) { $error->add('zeopilot_connection_unavailable', 'This connection is unavailable.'); } }, 10, 3); // This machine identity has no interactive login or password-reset workflow. add_filter('wp_authenticate_user', function ($user) { return zeopilot_connector_service_user($user) ? new WP_Error('zeopilot_machine_account', 'This account is for the limited connector only.') : $user; }, 99); add_filter('allow_password_reset', function ($allow, $user_id) { return zeopilot_connector_service_user(get_userdata($user_id)) ? false : $allow; }, 10, 2); /** Bound the machine account to content/data routes even if another plugin adds APIs. */ add_filter('rest_pre_dispatch', function ($result, $server, $request) { $user = wp_get_current_user(); if (!zeopilot_connector_service_user($user)) { return $result; } $route = rtrim($request->get_route(), '/'); $method = $request->get_method(); $read = in_array($method, array('GET', 'HEAD'), true); $allowed = $read && in_array($route, array('', '/wp/v2', '/website-autopilot/v1/status'), true); if ($read && preg_match('#^/wp/v2/posts(?:/[0-9]+)?$#', $route)) { $allowed = true; } if (preg_match('#^/wp/v2/pages(?:/[0-9]+)?$#', $route)) { $allowed = $read || in_array($method, array('POST', 'PUT', 'PATCH'), true); if (!$read) { $params = array_merge($request->get_query_params(), $request->get_body_params(), (array) $request->get_json_params()); $allowed = $allowed && !array_diff(array_keys($params), array('title', 'content', 'excerpt', 'status', 'slug', 'context', 'rest_route')); if (isset($params['status']) && !in_array($params['status'], array('publish', 'draft', 'pending', 'future', 'private'), true)) { $allowed = false; } } } if ($route === '/website-autopilot/v1/experiments') { $allowed = $read || $method === 'POST'; } elseif (preg_match('#^/website-autopilot/v1/experiments/[a-zA-Z0-9_-]+$#', $route)) { $allowed = $read || $method === 'DELETE'; } if (!$allowed || !zeopilot_connector_limited_user($user)) { return new WP_Error('zeopilot_content_scope_only', 'This connection is limited to page content and Zeopilot experiment data.', array('status' => 403)); } return $result; }, 1, 3); function zeopilot_connector_api_permission() { return current_user_can('manage_options') || (zeopilot_connector_limited_user(wp_get_current_user()) && current_user_can('zeopilot_connector_access')); } function zeopilot_connector_deactivate($network_wide) { if (is_multisite() && $network_wide) { foreach (get_sites(array('fields' => 'ids', 'number' => 0)) as $site_id) { switch_to_blog($site_id); zeopilot_connector_revoke(); restore_current_blog(); } } else { zeopilot_connector_revoke(); } }