forbidden(); } public function register_routes(): void { register_rest_route( $this->namespace, '/integrations', array( array( 'methods' => 'GET', 'callback' => array( $this, 'index' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); register_rest_route( $this->namespace, '/integrations/(?P[a-z0-9\-]+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'show' ), 'permission_callback' => array( $this, 'can_administer' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update' ), 'permission_callback' => array( $this, 'can_administer' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'disconnect' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); register_rest_route( $this->namespace, '/integrations/(?P[a-z0-9\-]+)/health', array( array( 'methods' => 'GET', 'callback' => array( $this, 'health' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); register_rest_route( $this->namespace, '/integrations/(?P[a-z0-9\-]+)/jobs', array( array( 'methods' => 'GET', 'callback' => array( $this, 'jobs' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); /* * THE SWITCH IS ITS OWN ROUTE, NOT A FIELD ON `PUT /integrations/{id}`, AND THAT IS A * DELIBERATE SEPARATION OF FAILURE MODES. `update()` runs `connect()`, which validates and * can refuse — so a toggle folded into it could fail because an unrelated required field was * left blank, and switching a connector OFF would be blocked by the state of a form. Worse, a * toggle that quietly re-saved credentials would put `connect()`'s side effects back through * the door they were just shown out of. {@see SECRET_SET} never enters this path at all. */ register_rest_route( $this->namespace, '/integrations/(?P[a-z0-9\-]+)/enabled', array( array( 'methods' => 'PUT', 'callback' => array( $this, 'set_enabled' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); register_rest_route( $this->namespace, '/integrations/(?P[a-z0-9\-]+)/options', array( array( 'methods' => 'PUT', 'callback' => array( $this, 'save_options' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); register_rest_route( $this->namespace, '/integrations/(?P[a-z0-9\-]+)/actions/(?P[a-z0-9\-]+)', array( array( 'methods' => 'POST', 'callback' => array( $this, 'run_action' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); register_rest_route( $this->namespace, '/integrations/(?P[a-z0-9\-]+)/jobs/(?P\d+)/retry', array( array( 'methods' => 'POST', 'callback' => array( $this, 'retry' ), 'permission_callback' => array( $this, 'can_administer' ), ), ) ); } /** * Switch one connector on or off. * * @param WP_REST_Request $request The request. */ public function set_enabled( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = sanitize_key( (string) $request['id'] ); $integration = $this->registry->get( $id ); if ( null === $integration ) { return $this->missing(); } $on = (bool) $request->get_param( 'enabled' ); if ( $on && ! $integration->available() ) { return new WP_Error( 'plugixa_crm_integration_unavailable', $integration->requirement(), array( 'status' => 400 ) ); } $this->state->set( $id, $on ); /* * SWITCHING OFF DROPS THE QUEUED WORK, exactly as {@see disconnect()} does and for the same * reason: jobs queued a moment ago would otherwise drain minutes later, on behalf of a * connector the site owner has just turned off. Both connectors also refuse at the point of * work, so this is belt and braces — but it is what stops the Configure screen's job log * filling with rows that did nothing. * * The deliberate consequence: a half-finished WooCommerce import is abandoned, and switching * the connector back on does not resume it. The Import order history button does. */ if ( ! $on ) { $this->jobs->forget_integration( $id ); } return $this->ok( $this->card( $integration ) ); } /** * Store this connector's own options. * * @param WP_REST_Request $request The request. */ public function save_options( WP_REST_Request $request ): WP_REST_Response|WP_Error { $integration = $this->registry->get( sanitize_key( (string) $request['id'] ) ); if ( ! $integration instanceof IntegrationOptionsInterface ) { return $this->missing(); } $input = $request->get_json_params(); $result = $integration->save_options( is_array( $input ) ? $input : array() ); if ( $result instanceof WP_Error ) { return $result; } return $this->ok( $integration->options() ); } /** * Do the thing on the button. * * @param WP_REST_Request $request The request. */ public function run_action( WP_REST_Request $request ): WP_REST_Response|WP_Error { $integration = $this->registry->get( sanitize_key( (string) $request['id'] ) ); if ( ! $integration instanceof IntegrationActionsInterface ) { return $this->missing(); } $result = $integration->run_action( sanitize_key( (string) $request['action'] ) ); if ( $result instanceof WP_Error ) { return $result; } return $this->ok( array( 'ran' => true ) ); } /** * Put a failed job back on the queue. * * THE JOB MUST BELONG TO THE CONNECTOR IN THE URL. Without that check a retry could be aimed at * any row in a shared table by id alone — every connector's queue is the same table — so an * administrator on one connector's screen could requeue another's work with no trace of why. * `404` rather than `403`: from the caller's position on this screen, a job belonging to somebody * else is a job that is not there. * * @param WP_REST_Request $request The request. */ public function retry( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = sanitize_key( (string) $request['id'] ); if ( ! $this->registry->has( $id ) ) { return $this->missing(); } $job = (int) $request['job']; $row = $this->jobs->find( $job ); if ( null === $row || $id !== (string) ( $row['integration'] ?? '' ) ) { return new WP_Error( 'plugixa_crm_job_not_found', __( 'That job is not on this connection.', 'plugixa-crm' ), array( 'status' => 404 ) ); } $this->jobs->requeue( $job ); return $this->ok( $this->jobs->recent( $id ) ); } /** * Every card: what is installed, plus what exists and is not. * * @param WP_REST_Request $request The request. */ public function index( WP_REST_Request $request ): WP_REST_Response { unset( $request ); $cards = array(); $installed = $this->registry->all(); foreach ( $installed as $id => $integration ) { $cards[ $id ] = $this->card( $integration ); } return $this->ok( array_values( $cards ) ); } /** * One connector, with the fields its connect form needs. * * @param WP_REST_Request $request The request. */ public function show( WP_REST_Request $request ): WP_REST_Response|WP_Error { $integration = $this->registry->get( sanitize_key( (string) $request['id'] ) ); if ( null === $integration ) { return $this->missing(); } $stored = $this->credentials->get( $integration->id() ); $fields = array(); foreach ( $integration->settings_schema() as $field ) { $key = (string) ( $field['key'] ?? '' ); $type = (string) ( $field['type'] ?? 'text' ); $value = (string) ( $stored[ $key ] ?? '' ); $fields[] = array( 'key' => $key, 'label' => (string) ( $field['label'] ?? $key ), 'type' => $type, 'help' => (string) ( $field['help'] ?? '' ), 'required' => (bool) ( $field['required'] ?? false ), // A password field NEVER carries its value out of here. Empty means "nothing stored"; // the sentinel means "something is, and you do not need to see it". 'value' => 'password' === $type ? ( '' === $value ? '' : self::SECRET_SET ) : $value, ); } return $this->ok( array_merge( $this->card( $integration ), array( 'fields' => $fields, // Two OPTIONAL interfaces, so a connector with nothing to configure and nothing // to be asked simply reports empty here and the screen renders neither section. 'optionsSchema' => $integration instanceof IntegrationOptionsInterface ? $integration->options_schema() : (object) array(), 'options' => $integration instanceof IntegrationOptionsInterface ? $integration->options() : (object) array(), 'actions' => $integration instanceof IntegrationActionsInterface ? $integration->actions() : array(), ) ) ); } /** * Connect, or update a connection. * * @param WP_REST_Request $request The request. */ public function update( WP_REST_Request $request ): WP_REST_Response|WP_Error { $integration = $this->registry->get( sanitize_key( (string) $request['id'] ) ); if ( null === $integration ) { return $this->missing(); } if ( ! $integration->available() ) { // Refused rather than stored. Accepting credentials for a connector that cannot run leaves // a site with a connection that reports connected and never does anything. return new WP_Error( 'plugixa_crm_integration_unavailable', '' !== $integration->requirement() ? $integration->requirement() : __( 'That integration cannot be used on this site yet.', 'plugixa-crm' ), array( 'status' => 400 ) ); } $input = (array) $request->get_json_params(); $values = array(); foreach ( $integration->settings_schema() as $field ) { $key = (string) ( $field['key'] ?? '' ); if ( '' === $key || ! array_key_exists( $key, $input ) ) { continue; } $value = (string) $input[ $key ]; // The sentinel means the user never touched the field. Dropping it here is what makes the // connector's merge leave the stored secret alone; writing it through would replace an API // key with the literal string, and the failure would surface days later as a 401. if ( self::SECRET_SET === $value ) { continue; } $values[ $key ] = sanitize_text_field( $value ); } $result = $integration->connect( $values ); if ( $result instanceof WP_Error ) { return $result; } return $this->ok( $this->card( $integration ) ); } /** * Forget a connection. * * @param WP_REST_Request $request The request. */ public function disconnect( WP_REST_Request $request ): WP_REST_Response|WP_Error { $integration = $this->registry->get( sanitize_key( (string) $request['id'] ) ); if ( null === $integration ) { return $this->missing(); } $integration->disconnect(); $this->credentials->forget( $integration->id() ); /* * SWITCHED OFF AS WELL AS FORGOTTEN. A connector left switched on with its credentials gone * would keep queueing work that cannot succeed, and the card would read Enabled beside Not * connected for ever. Forgetting the flag rather than storing a `false` also keeps the option * from growing a row for every connector a site has ever had. */ $this->state->forget( $integration->id() ); /* * SYNCED DATA STAYS, AND SO DO THE OPTIONS. Disconnecting is "stop talking to them", and * somebody who reconnects an hour later after rotating a key expects their contacts — and the * threshold and pipeline they chose — to still be there. Queued work is dropped, though: * those jobs would run against credentials that no longer exist and fail five times each on * their way to being given up on. */ $this->jobs->forget_integration( $integration->id() ); return $this->ok( $this->card( $integration ) ); } /** * Ask the far end how it is doing. * * ITS OWN ROUTE BECAUSE IT MAY MAKE A NETWORK CALL. Folding it into the card list would make the * screen as slow as the slowest third party, and would fail to render at all when one is down. * * @param WP_REST_Request $request The request. */ public function health( WP_REST_Request $request ): WP_REST_Response|WP_Error { $integration = $this->registry->get( sanitize_key( (string) $request['id'] ) ); if ( null === $integration ) { return $this->missing(); } /* * A SWITCHED-OFF CONNECTOR IS NOT UNHEALTHY, AND ASKING IT WOULD BE WORSE THAN USELESS. * `health()` may make a network call, so asking one nobody is using spends a request on a * third party to be told about a connection the site owner turned off on purpose — and a slow * or down third party would then make this route slow or failing too. Answered here rather * than in three `health()` implementations, for the same reason the switch itself is not on * the interface: three copies of one rule is how they come to disagree. */ if ( ! $this->state->is_enabled( $integration->id() ) ) { return $this->ok( array( 'status' => 'warning', 'message' => __( 'This connection is switched off.', 'plugixa-crm' ), ) ); } $health = $integration->health(); return $this->ok( array( 'status' => (string) ( $health['status'] ?? 'error' ), 'message' => (string) ( $health['message'] ?? '' ), ) ); } /** * The recent queue rows for one connector — the card's error log. * * @param WP_REST_Request $request The request. */ public function jobs( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = sanitize_key( (string) $request['id'] ); if ( ! $this->registry->has( $id ) ) { return $this->missing(); } return $this->ok( $this->jobs->recent( $id ) ); } /** * The card for an installed connector. * * @param IntegrationInterface $integration The connector. * * @return array */ private function card( IntegrationInterface $integration ): array { $id = $integration->id(); $available = $integration->available(); return array( 'id' => $id, 'label' => $integration->label(), 'description' => $integration->description(), 'category' => Catalogue::category( $id ), 'installed' => true, 'available' => $available, 'requirement' => $available ? '' : $integration->requirement(), // TWO DIFFERENT QUESTIONS, AND THE CARD ASKS BOTH. `enabled` is whether the site owner has // switched it on; `connected` is whether it has what it needs to run. A connector can be // configured and switched off, or switched on and missing half its settings, and only // showing both tells those apart. 'enabled' => $this->state->is_enabled( $id ), 'connected' => $available && $integration->connected(), // The state the credential store can express and `connected()` cannot: something IS // stored, and the salts have moved under it. "Connect" and "sign in again" are different // sentences and the screen needs to know which to show. 'needsReconnect' => $this->credentials->needs_reconnect( $id ), 'links' => $this->links->count_for( $id ), 'jobs' => $this->jobs->tally( $id ), ); } /** * The one refusal a catalogue-only id gets. * * A CATALOGUE ENTRY HAS NO ROUTE, and this is what enforces it. Every action above resolves * through the registry, so an id that is only advertised answers 404 rather than doing anything — * which is what keeps a locked card an advertisement rather than a broken feature. */ private function missing(): WP_Error { return new WP_Error( 'plugixa_crm_integration_not_installed', __( 'That integration is not installed on this site.', 'plugixa-crm' ), array( 'status' => 404 ) ); } }