From 016f0cc92195b67dbed011f3a433f05168558168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 7 Aug 2026 03:09:29 +0200 Subject: [PATCH 1/2] Add Team-scoped WordPress OAuth --- .github/workflows/ci.yml | 2 + README.md | 41 +-- bin/test-wordpress.sh | 20 ++ includes/class-spacefast-client.php | 22 +- includes/class-spacefast-oauth.php | 223 ++++++++++++++ includes/class-spacefast-plugin.php | 271 ++++++++++++++---- includes/class-spacefast-settings.php | 159 ++++------ .../class-spacefast-simply-static-task.php | 8 + includes/class-spacefast-sync-state.php | 4 + readme.txt | 19 +- spacefast-wordpress.php | 6 +- tests/acceptance/compose.yml | 50 ++++ tests/acceptance/wordpress.php | 53 ++++ tests/behavior.php | 155 ++++++++-- uninstall.php | 3 + 15 files changed, 824 insertions(+), 212 deletions(-) create mode 100755 bin/test-wordpress.sh create mode 100644 includes/class-spacefast-oauth.php create mode 100644 tests/acceptance/compose.yml create mode 100644 tests/acceptance/wordpress.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a6b69d..62e204d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,5 @@ jobs: run: sudo apt-get update && sudo apt-get install --yes php-cli zip unzip - name: Test run: ./bin/test.sh + - name: WordPress acceptance + run: ./bin/test-wordpress.sh diff --git a/README.md b/README.md index 883765a..b45a268 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,17 @@ One WordPress plugin, two ways to publish a static site to Spacefast: ## Install -1. In Spacefast, open the Space's WordPress data-source setup and choose - **Connect WordPress**. -2. Download [`spacefast-wordpress.zip`](https://github.com/spacefast/wordpress/releases/latest/download/spacefast-wordpress.zip) - and create the one-time connection. -3. In WordPress, upload and activate the ZIP, then open **Settings → Spacefast**. -4. Paste the complete connection, choose a publishing mode, and test it. - -The generated credential is scoped to one Space. It can trigger that Space's -production repository, publish static files to it, and read its version status. -It cannot edit WordPress, change Space settings, manage domains, or access -another Space. The secret is stored in a non-autoloaded WordPress option and is -never displayed after save. A managed host can instead define -`SPACEFAST_WORDPRESS_API_URL`, `SPACEFAST_WORDPRESS_SPACE_ID`, and -`SPACEFAST_WORDPRESS_TOKEN` in `wp-config.php`. +1. Download [`spacefast-wordpress.zip`](https://github.com/spacefast/wordpress/releases/latest/download/spacefast-wordpress.zip), then install and activate it. +2. Open **Settings → Spacefast** and choose a publishing mode. +3. Continue to Spacefast, sign in, and authorize exactly one Team. +4. Back in WordPress, choose the Space. The plugin verifies it before showing **Connected**. + +OAuth access is bounded by the user's live Team role, the one Team approved on +the consent screen, and the chosen mode: static mode can publish to Spaces; +headless mode can trigger repository builds. WordPress then selects one Space +inside that Team. Tokens are stored in non-autoloaded options and are never +displayed. `SPACEFAST_WORDPRESS_API_URL` may override the API origin for local +development. ## Static WordPress @@ -36,8 +33,9 @@ its files but before cleanup. The step hashes the generated bytes, declares a full export as an exact snapshot through Spacefast's normal version API, uploads only the files Spacefast requests, waits for activation, and records whether that version became live. Partial, update, build, and 404-only exports -are additive so they cannot accidentally delete omitted site files; run a full -export after deleting content. Upload transfer is paged and processed one file +are additive so they cannot accidentally delete omitted site files. After a +deletion, the plugin blocks partial publishing until a full export removes old +files safely. Upload transfer is paged and processed one file per Simply Static background step so a large upload does not become one unbounded WordPress request. @@ -70,8 +68,9 @@ The settings page shows the active mode, pending work, delivery status, and the last build or version ID. It also provides a connection test and appears in Site Health. -**Disconnect** removes the local secret and pending work. Revoke the matching -WordPress key in Spacefast Developer settings. Uninstall removes all plugin +**Disconnect** revokes refresh access in Spacefast, removes local tokens, and +clears pending work. A short-lived access token may remain valid until it +expires; Connected Apps can revoke the Team grant immediately. Uninstall removes all plugin options, cron events, locks, notices, and interrupted static-publish state; it never deletes WordPress content or Simply Static exports. @@ -81,11 +80,13 @@ From this directory: ```bash ./bin/test.sh +./bin/test-wordpress.sh ``` -This lints every runtime PHP file, runs dependency-free behavioral tests, builds +The first command lints every runtime PHP file, runs dependency-free behavioral tests, builds the deterministic installable ZIP in `dist/`, checks the archive, and proves a -second build produces identical bytes. +second build produces identical bytes. The second runs activation and integration +acceptance against WordPress 6.5 on PHP 8.1 in Docker. Private beta supports WordPress 6.5+, PHP 8.1+, and Simply Static 3.8+ for direct static publishing. Activate it per site; network activation is intentionally diff --git a/bin/test-wordpress.sh b/bin/test-wordpress.sh new file mode 100755 index 0000000..9298e71 --- /dev/null +++ b/bin/test-wordpress.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +plugin_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +compose_file="${plugin_root}/tests/acceptance/compose.yml" +project_name="spacefast-wordpress-acceptance" + +cleanup() { + docker compose --project-name "${project_name}" --file "${compose_file}" down --volumes --remove-orphans +} +trap cleanup EXIT + +docker compose --project-name "${project_name}" --file "${compose_file}" up --detach --wait db wordpress +docker compose --project-name "${project_name}" --file "${compose_file}" run --rm wpcli \ + wp core install --url=http://wordpress --title=Spacefast --admin_user=admin \ + --admin_password=acceptance-password --admin_email=acceptance@example.test +docker compose --project-name "${project_name}" --file "${compose_file}" run --rm wpcli \ + wp plugin activate spacefast-wordpress +docker compose --project-name "${project_name}" --file "${compose_file}" run --rm wpcli \ + wp eval-file wp-content/plugins/spacefast-wordpress/tests/acceptance/wordpress.php diff --git a/includes/class-spacefast-client.php b/includes/class-spacefast-client.php index 43112a2..4884dc7 100644 --- a/includes/class-spacefast-client.php +++ b/includes/class-spacefast-client.php @@ -18,6 +18,16 @@ public function __construct( ?callable $transport = null, ?callable $upload_tran */ private function request( string $method, string $path, ?array $body = null, array $headers = array() ): array { $settings = Spacefast_Settings::get(); + $access_token = ( new Spacefast_OAuth( $this->transport ) )->access_token(); + if ( '' === $access_token ) { + return array( + 'ok' => false, + 'retryable' => false, + 'code' => 'reauthorization_required', + 'message' => 'Reconnect Spacefast to continue.', + 'data' => array(), + ); + } $args = array( 'method' => $method, 'timeout' => 10, @@ -27,7 +37,7 @@ private function request( string $method, string $path, ?array $body = null, arr 'limit_response_size' => 65536, 'headers' => array_merge( array( - 'Authorization' => 'Bearer ' . $settings['token'], + 'Authorization' => 'Bearer ' . $access_token, 'Accept' => 'application/json', 'User-Agent' => 'Spacefast-WordPress/' . SPACEFAST_WORDPRESS_VERSION, ), @@ -83,6 +93,16 @@ private function request( string $method, string $path, ?array $body = null, arr ); } + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ + public function list_teams(): array { + return $this->request( 'GET', '/v1/teams?limit=100' ); + } + + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ + public function list_spaces(): array { + return $this->request( 'GET', '/v1/spaces?limit=100' ); + } + /** * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ diff --git a/includes/class-spacefast-oauth.php b/includes/class-spacefast-oauth.php new file mode 100644 index 0000000..4509a63 --- /dev/null +++ b/includes/class-spacefast-oauth.php @@ -0,0 +1,223 @@ +transport = $transport ?? 'wp_safe_remote_request'; + } + + /** @return array */ + public static function scopes( string $mode ): array { + return array( + 'teams:read', + 'spaces:read', + Spacefast_Settings::MODE_STATIC === $mode ? 'spaces:publish' : 'builds:trigger', + 'offline_access', + ); + } + + public static function callback_url(): string { + return add_query_arg( + array( 'page' => 'spacefast-wordpress', 'spacefast_oauth' => 'callback' ), + admin_url( 'options-general.php' ) + ); + } + + public static function pkce_challenge( string $verifier ): string { + return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); + } + + /** @return array{ok:bool,message:string,url?:string} */ + public function begin( string $mode ): array { + if ( ! in_array( $mode, Spacefast_Settings::modes(), true ) ) { + return array( 'ok' => false, 'message' => 'Choose a publishing mode.' ); + } + $api_url = Spacefast_Settings::api_url(); + $callback = self::callback_url(); + $scopes = implode( ' ', self::scopes( $mode ) ); + $resource = $api_url . self::RESOURCE_PATH; + $registration = $this->json_request( + 'POST', + $api_url . '/v1/auth/oauth2/register', + array( + 'client_name' => 'Spacefast for WordPress', + 'client_uri' => 'https://github.com/spacefast/wordpress', + 'redirect_uris' => array( $callback ), + 'grant_types' => array( 'authorization_code', 'refresh_token' ), + 'response_types' => array( 'code' ), + 'token_endpoint_auth_method' => 'none', + 'scope' => $scopes, + 'resources' => array( $resource ), + ) + ); + if ( ! $registration['ok'] || empty( $registration['data']['client_id'] ) ) { + return array( 'ok' => false, 'message' => $registration['message'] ?: 'Spacefast could not start authorization.' ); + } + $client_id = (string) $registration['data']['client_id']; + $state = wp_generate_password( 48, false, false ); + $verifier = wp_generate_password( 96, false, false ); + update_option( + self::PENDING_OPTION, + array( + 'state' => $state, + 'verifier' => $verifier, + 'client_id' => $client_id, + 'mode' => $mode, + 'callback' => $callback, + 'expires_at' => time() + 10 * MINUTE_IN_SECONDS, + ), + false + ); + $query = array( + 'response_type' => 'code', + 'client_id' => $client_id, + 'redirect_uri' => $callback, + 'scope' => $scopes, + 'state' => $state, + 'code_challenge' => self::pkce_challenge( $verifier ), + 'code_challenge_method' => 'S256', + 'resource' => $resource, + ); + return array( 'ok' => true, 'message' => '', 'url' => add_query_arg( $query, $api_url . '/v1/auth/oauth2/authorize' ) ); + } + + /** @return array{ok:bool,message:string} */ + public function finish( string $code, string $state ): array { + $pending = get_option( self::PENDING_OPTION, array() ); + delete_option( self::PENDING_OPTION ); + if ( ! is_array( $pending ) || time() > (int) ( $pending['expires_at'] ?? 0 ) ) { + return array( 'ok' => false, 'message' => 'Authorization expired. Start again.' ); + } + if ( ! hash_equals( (string) ( $pending['state'] ?? '' ), $state ) || '' === $code ) { + return array( 'ok' => false, 'message' => 'Authorization could not be verified. Start again.' ); + } + $tokens = $this->form_request( + Spacefast_Settings::api_url() . '/v1/auth/oauth2/token', + array( + 'grant_type' => 'authorization_code', + 'client_id' => (string) $pending['client_id'], + 'redirect_uri' => (string) $pending['callback'], + 'code' => $code, + 'code_verifier' => (string) $pending['verifier'], + 'resource' => Spacefast_Settings::api_url() . self::RESOURCE_PATH, + ) + ); + if ( ! $tokens['ok'] ) return array( 'ok' => false, 'message' => $tokens['message'] ); + $data = $tokens['data']; + if ( empty( $data['access_token'] ) || empty( $data['refresh_token'] ) ) { + return array( 'ok' => false, 'message' => 'Spacefast returned an incomplete authorization.' ); + } + Spacefast_Settings::merge( + array( + 'mode' => (string) $pending['mode'], + 'client_id' => (string) $pending['client_id'], + 'access_token' => (string) $data['access_token'], + 'refresh_token' => (string) $data['refresh_token'], + 'expires_at' => time() + max( 60, (int) ( $data['expires_in'] ?? 900 ) ), + 'scope' => (string) ( $data['scope'] ?? '' ), + 'team_id' => '', 'space_id' => '', 'verified_at' => 0, + ) + ); + return $this->load_choices(); + } + + /** @return array{ok:bool,message:string} */ + public function load_choices(): array { + $client = new Spacefast_Client( $this->transport ); + $teams = $client->list_teams(); + $spaces = $client->list_spaces(); + if ( ! $teams['ok'] ) return array( 'ok' => false, 'message' => $teams['message'] ); + if ( ! $spaces['ok'] ) return array( 'ok' => false, 'message' => $spaces['message'] ); + update_option( + self::CHOICES_OPTION, + array( 'teams' => $teams['data'], 'spaces' => $spaces['data'], 'expires_at' => time() + 10 * MINUTE_IN_SECONDS ), + false + ); + return array( 'ok' => true, 'message' => '' ); + } + + /** @return array{ok:bool,message:string} */ + public function refresh(): array { + $settings = Spacefast_Settings::get(); + $result = $this->form_request( + $settings['api_url'] . '/v1/auth/oauth2/token', + array( + 'grant_type' => 'refresh_token', + 'client_id' => (string) $settings['client_id'], + 'refresh_token' => (string) $settings['refresh_token'], + 'resource' => $settings['api_url'] . self::RESOURCE_PATH, + ) + ); + if ( ! $result['ok'] ) return array( 'ok' => false, 'message' => $result['message'] ); + $data = $result['data']; + if ( empty( $data['access_token'] ) || empty( $data['refresh_token'] ) ) { + return array( 'ok' => false, 'message' => 'Spacefast returned an incomplete token refresh.' ); + } + Spacefast_Settings::merge( + array( + 'access_token' => (string) $data['access_token'], + 'refresh_token' => (string) $data['refresh_token'], + 'expires_at' => time() + max( 60, (int) ( $data['expires_in'] ?? 900 ) ), + 'scope' => (string) ( $data['scope'] ?? $settings['scope'] ), + ) + ); + return array( 'ok' => true, 'message' => '' ); + } + + public function access_token(): string { + $settings = Spacefast_Settings::get(); + if ( (int) $settings['expires_at'] <= time() + 60 ) { + $result = $this->refresh(); + if ( ! $result['ok'] ) return ''; + $settings = Spacefast_Settings::get(); + } + return (string) $settings['access_token']; + } + + public function revoke(): void { + $settings = Spacefast_Settings::get(); + foreach ( array( $settings['refresh_token'], $settings['access_token'] ) as $token ) { + if ( ! is_string( $token ) || '' === $token ) continue; + $this->form_request( + $settings['api_url'] . '/v1/auth/oauth2/revoke', + array( 'token' => $token, 'client_id' => (string) $settings['client_id'] ) + ); + } + } + + /** @return array{ok:bool,message:string,data:array} */ + private function json_request( string $method, string $url, array $body ): array { + return $this->response( call_user_func( $this->transport, $url, array( + 'method' => $method, 'timeout' => 15, 'redirection' => 0, 'reject_unsafe_urls' => true, + 'sslverify' => true, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( $body ), + ) ) ); + } + + /** @return array{ok:bool,message:string,data:array} */ + private function form_request( string $url, array $body ): array { + return $this->response( call_user_func( $this->transport, $url, array( + 'method' => 'POST', 'timeout' => 15, 'redirection' => 0, 'reject_unsafe_urls' => true, + 'sslverify' => true, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded' ), + 'body' => http_build_query( $body, '', '&', PHP_QUERY_RFC3986 ), + ) ) ); + } + + /** @param mixed $response Response. @return array{ok:bool,message:string,data:array} */ + private function response( $response ): array { + if ( is_wp_error( $response ) ) return array( 'ok' => false, 'message' => 'Spacefast could not be reached.', 'data' => array() ); + $status = (int) wp_remote_retrieve_response_code( $response ); + $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + $data = is_array( $data ) ? $data : array(); + if ( 200 <= $status && 300 > $status ) return array( 'ok' => true, 'message' => '', 'data' => $data ); + return array( 'ok' => false, 'message' => (string) ( $data['error_description'] ?? $data['detail'] ?? 'Spacefast rejected the request.' ), 'data' => array() ); + } +} diff --git a/includes/class-spacefast-plugin.php b/includes/class-spacefast-plugin.php index 43888ee..34354d0 100644 --- a/includes/class-spacefast-plugin.php +++ b/includes/class-spacefast-plugin.php @@ -11,9 +11,10 @@ final class Spacefast_Plugin { public static function boot(): void { add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) ); - add_action( 'admin_post_spacefast_wordpress_connect', array( __CLASS__, 'connect' ) ); + add_action( 'admin_init', array( __CLASS__, 'oauth_callback' ) ); + add_action( 'admin_post_spacefast_wordpress_oauth_start', array( __CLASS__, 'oauth_start' ) ); + add_action( 'admin_post_spacefast_wordpress_select_space', array( __CLASS__, 'select_space' ) ); add_action( 'admin_post_spacefast_wordpress_disconnect', array( __CLASS__, 'disconnect' ) ); - add_action( 'admin_post_spacefast_wordpress_mode', array( __CLASS__, 'save_mode' ) ); add_action( 'admin_post_spacefast_wordpress_test', array( __CLASS__, 'test_connection' ) ); add_action( 'admin_post_spacefast_wordpress_build', array( __CLASS__, 'manual_build' ) ); add_action( 'admin_post_spacefast_wordpress_publish', array( __CLASS__, 'manual_static_publish' ) ); @@ -24,10 +25,10 @@ public static function boot(): void { add_action( 'save_post_attachment', array( __CLASS__, 'attachment_changed' ) ); add_action( 'edit_attachment', array( __CLASS__, 'attachment_changed' ) ); add_action( 'add_attachment', array( __CLASS__, 'attachment_changed' ) ); - add_action( 'delete_attachment', array( __CLASS__, 'attachment_changed' ) ); + add_action( 'delete_attachment', array( __CLASS__, 'deletion_changed' ) ); add_action( 'created_term', array( __CLASS__, 'term_changed' ) ); add_action( 'edited_term', array( __CLASS__, 'term_changed' ) ); - add_action( 'delete_term', array( __CLASS__, 'term_changed' ) ); + add_action( 'delete_term', array( __CLASS__, 'deletion_changed' ) ); add_action( 'wp_update_nav_menu', array( __CLASS__, 'menu_changed' ) ); add_action( 'profile_update', array( __CLASS__, 'user_changed' ) ); add_action( 'user_register', array( __CLASS__, 'user_changed' ) ); @@ -88,37 +89,95 @@ private static function notice( string $type, string $message ): void { ); } - public static function connect(): void { - self::assert_admin( 'spacefast_wordpress_connect' ); - $raw = isset( $_POST['connection'] ) - ? sanitize_textarea_field( wp_unslash( $_POST['connection'] ) ) - : ''; + public static function oauth_start(): void { + self::assert_admin( 'spacefast_wordpress_oauth_start' ); + $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : ''; try { - Spacefast_Settings::save( Spacefast_Settings::parse_connection( $raw ) ); - self::notice( 'success', __( 'Spacefast connection saved.', 'spacefast-wordpress' ) ); - } catch ( InvalidArgumentException $error ) { + Spacefast_Settings::save_mode( $mode ); + $result = ( new Spacefast_OAuth() )->begin( $mode ); + if ( ! $result['ok'] ) throw new RuntimeException( $result['message'] ); + wp_redirect( esc_url_raw( (string) $result['url'] ) ); + exit; + } catch ( Throwable $error ) { self::notice( 'error', $error->getMessage() ); + self::redirect(); } - self::redirect(); } - public static function disconnect(): void { - self::assert_admin( 'spacefast_wordpress_disconnect' ); - Spacefast_Settings::disconnect(); - self::notice( 'success', __( 'Spacefast disconnected. Revoke its key in Spacefast Developer settings.', 'spacefast-wordpress' ) ); + public static function oauth_callback(): void { + if ( 'callback' !== ( $_GET['spacefast_oauth'] ?? '' ) ) return; + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You are not allowed to manage Spacefast.', 'spacefast-wordpress' ), 403 ); + } + if ( isset( $_GET['error'] ) ) { + self::notice( 'error', sanitize_text_field( wp_unslash( $_GET['error_description'] ?? $_GET['error'] ) ) ); + self::redirect(); + } + $code = sanitize_text_field( wp_unslash( $_GET['code'] ?? '' ) ); + $state = sanitize_text_field( wp_unslash( $_GET['state'] ?? '' ) ); + $result = ( new Spacefast_OAuth() )->finish( $code, $state ); + self::notice( + $result['ok'] ? 'success' : 'error', + $result['ok'] + ? __( 'Team authorized. Choose the Space WordPress should use.', 'spacefast-wordpress' ) + : $result['message'] + ); self::redirect(); } - public static function save_mode(): void { - self::assert_admin( 'spacefast_wordpress_mode' ); - $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : ''; - try { - Spacefast_Settings::save_mode( $mode ); - Spacefast_Static_Publisher::reset(); - self::notice( 'success', __( 'Publishing mode saved.', 'spacefast-wordpress' ) ); - } catch ( InvalidArgumentException $error ) { - self::notice( 'error', $error->getMessage() ); + public static function select_space(): void { + self::assert_admin( 'spacefast_wordpress_select_space' ); + $space_id = sanitize_text_field( wp_unslash( $_POST['space_id'] ?? '' ) ); + $choices = get_option( Spacefast_OAuth::CHOICES_OPTION, array() ); + if ( ! preg_match( '/^spc_[A-Za-z0-9_-]+$/', $space_id ) || time() > (int) ( $choices['expires_at'] ?? 0 ) ) { + self::notice( 'error', __( 'Space choices expired. Authorize again.', 'spacefast-wordpress' ) ); + self::redirect(); + } + $spaces = is_array( $choices['spaces'] ?? null ) ? $choices['spaces'] : array(); + $teams = is_array( $choices['teams'] ?? null ) ? $choices['teams'] : array(); + $space = null; + foreach ( $spaces as $candidate ) { + if ( is_array( $candidate ) && hash_equals( (string) ( $candidate['id'] ?? '' ), $space_id ) ) $space = $candidate; + } + if ( ! is_array( $space ) ) { + self::notice( 'error', __( 'That Space is no longer available. Authorize again.', 'spacefast-wordpress' ) ); + self::redirect(); + } + $team_id = (string) ( $space['teamId'] ?? '' ); + $team = null; + foreach ( $teams as $candidate ) { + if ( is_array( $candidate ) && hash_equals( (string) ( $candidate['id'] ?? '' ), $team_id ) ) $team = $candidate; } + Spacefast_Settings::merge( + array( + 'team_id' => $team_id, + 'team_name' => (string) ( $team['name'] ?? $space['teamSlug'] ?? '' ), + 'team_slug' => (string) ( $team['slug'] ?? $space['teamSlug'] ?? '' ), + 'space_id' => (string) $space['id'], + 'space_name' => (string) ( $space['title'] ?? $space['name'] ?? $space['slug'] ?? '' ), + 'space_slug' => (string) ( $space['slug'] ?? '' ), + 'live_url' => (string) ( $space['liveUrl'] ?? '' ), + 'verified_at' => 0, + ) + ); + $result = ( new Spacefast_Client() )->health(); + if ( $result['ok'] ) { + Spacefast_Settings::merge( array( 'verified_at' => time() ) ); + delete_option( Spacefast_OAuth::CHOICES_OPTION ); + self::configure_simply_static(); + } + self::notice( + $result['ok'] ? 'success' : 'error', + $result['ok'] ? __( 'Spacefast is connected.', 'spacefast-wordpress' ) : $result['message'] + ); + self::redirect(); + } + + public static function disconnect(): void { + self::assert_admin( 'spacefast_wordpress_disconnect' ); + ( new Spacefast_OAuth() )->revoke(); + Spacefast_Settings::disconnect(); + self::notice( 'success', __( 'Spacefast disconnected and its refresh access was revoked.', 'spacefast-wordpress' ) ); self::redirect(); } @@ -192,8 +251,15 @@ public static function post_deleted( int $post_id, WP_Post $post ): void { return; } if ( 'publish' === $post->post_status ) { - self::record_change( 'post_deleted' ); + self::deletion_changed(); + } + } + + public static function deletion_changed(): void { + if ( Spacefast_Settings::MODE_STATIC === Spacefast_Settings::mode() ) { + update_option( 'spacefast_wordpress_snapshot_required', 1, false ); } + self::record_change( 'deleted' ); } public static function term_changed(): void { @@ -263,6 +329,12 @@ public static function deliver(): void { if ( $generation <= (int) $snapshot['delivered'] || '' === $snapshot['event_id'] ) { return; } + Spacefast_Sync_State::mutate( + static function ( array $state ): array { + $state['last_attempt_at'] = time(); + return $state; + } + ); $result = ( new Spacefast_Client() )->trigger_build( (string) $snapshot['event_id'] ); $current = Spacefast_Sync_State::get(); if ( $result['ok'] ) { @@ -307,12 +379,26 @@ public static function register_simply_static(): void { if ( ! self::simply_static_available() ) { return; } + self::configure_simply_static(); require_once dirname( __DIR__ ) . '/includes/class-spacefast-simply-static-task.php'; add_filter( 'simplystatic.archive_creation_job.task_list', array( __CLASS__, 'simply_static_tasks' ), 20, 2 ); add_filter( 'simply_static_class_name', array( __CLASS__, 'simply_static_task_class' ), 20, 2 ); add_action( 'ss_archive_creation_job_after_start_queue', array( __CLASS__, 'static_export_started' ), 20 ); } + private static function configure_simply_static(): void { + if ( Spacefast_Settings::MODE_STATIC !== Spacefast_Settings::mode() || ! self::simply_static_available() ) return; + $live_url = (string) Spacefast_Settings::get()['live_url']; + $parts = wp_parse_url( $live_url ); + if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) return; + $host = (string) $parts['host'] . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ); + \Simply_Static\Options::instance() + ->set( 'destination_url_type', 'absolute' ) + ->set( 'destination_scheme', (string) $parts['scheme'] . '://' ) + ->set( 'destination_host', $host ) + ->save(); + } + public static function simply_static_available(): bool { return class_exists( '\\Simply_Static\\Plugin' ) && class_exists( '\\Simply_Static\\Task' ) @@ -395,9 +481,57 @@ public static function site_health_tests( array $tests ): array { 'label' => __( 'Spacefast connection', 'spacefast-wordpress' ), 'test' => array( __CLASS__, 'site_health_result' ), ); + if ( Spacefast_Settings::MODE_HEADLESS === Spacefast_Settings::mode() ) { + $tests['direct']['spacefast_cron'] = array( + 'label' => __( 'Spacefast background delivery', 'spacefast-wordpress' ), + 'test' => array( __CLASS__, 'site_health_cron_result' ), + ); + } return $tests; } + /** @return array */ + public static function site_health_cron_result(): array { + $state = Spacefast_Sync_State::get(); + $pending = (int) $state['desired'] > (int) $state['delivered']; + $disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; + $missing = $pending && false === wp_next_scheduled( Spacefast_Sync_State::HOOK ); + $delayed = $pending && ( $missing || ( 0 < (int) $state['next_at'] && (int) $state['next_at'] + 300 < time() ) ); + $healthy = ! $disabled && ! $delayed; + return array( + 'label' => $healthy + ? __( 'Spacefast background delivery is ready', 'spacefast-wordpress' ) + : __( 'Spacefast background delivery needs attention', 'spacefast-wordpress' ), + 'status' => $healthy ? 'good' : 'recommended', + 'badge' => array( 'label' => 'Spacefast', 'color' => 'blue' ), + 'description' => '

' . esc_html( + $disabled + ? __( 'WP-Cron is disabled. Configure the server to request wp-cron.php regularly so content changes trigger builds.', 'spacefast-wordpress' ) + : ( $delayed + ? __( 'A build trigger is delayed. The current live site is safe; open Settings → Spacefast and retry.', 'spacefast-wordpress' ) + : __( 'WordPress can deliver content changes in the background.', 'spacefast-wordpress' ) ) + ) . '

', + 'test' => 'spacefast_cron', + ); + } + + public static function status_label( string $status, bool $static_mode ): string { + $labels = array( + 'idle' => __( 'Ready', 'spacefast-wordpress' ), + 'pending' => $static_mode ? __( 'Waiting for export', 'spacefast-wordpress' ) : __( 'Changes waiting to build', 'spacefast-wordpress' ), + 'delivered' => __( 'Build started', 'spacefast-wordpress' ), + 'exporting' => __( 'Generating static site', 'spacefast-wordpress' ), + 'uploading' => __( 'Publishing files', 'spacefast-wordpress' ), + 'finalizing' => __( 'Activating new version', 'spacefast-wordpress' ), + 'live' => __( 'Published live', 'spacefast-wordpress' ), + 'ready' => __( 'Version ready', 'spacefast-wordpress' ), + 'unchanged' => __( 'Already up to date', 'spacefast-wordpress' ), + 'retrying' => __( 'Retrying automatically', 'spacefast-wordpress' ), + 'blocked' => __( 'Needs attention', 'spacefast-wordpress' ), + ); + return (string) ( $labels[ $status ] ?? __( 'Unknown', 'spacefast-wordpress' ) ); + } + /** * @return array */ @@ -433,6 +567,8 @@ public static function render_admin(): void { $notice = get_transient( self::NOTICE_TRANSIENT ); delete_transient( self::NOTICE_TRANSIENT ); $state = Spacefast_Sync_State::get(); + $settings = Spacefast_Settings::get(); + $authorized = Spacefast_Settings::authorized(); $configured = Spacefast_Settings::configured(); $mode = Spacefast_Settings::mode(); $static_mode = Spacefast_Settings::MODE_STATIC === $mode; @@ -443,44 +579,53 @@ public static function render_admin(): void {

- -

-

+ +

- - - - - -
- -

-
- - -
- - + + +
+ +
- +

+

+ + +

+

+ + +
+ + +
+ + +
+ +

+ + + +

— /

+

.

- + - +
+

-

+

@@ -491,12 +636,24 @@ public static function render_admin(): void {
-

+

' . esc_html__( 'Activate Simply Static', 'spacefast-wordpress' ) . ''; + } + if ( ! current_user_can( 'install_plugins' ) ) return esc_html__( 'Ask an administrator to install it.', 'spacefast-wordpress' ); + $url = wp_nonce_url( self_admin_url( 'update.php?action=install-plugin&plugin=simply-static' ), 'install-plugin_simply-static' ); + return '' . esc_html__( 'Install Simply Static', 'spacefast-wordpress' ) . ''; + } + private static function action_form( string $action, string $label, string $class ): void { ?>
diff --git a/includes/class-spacefast-settings.php b/includes/class-spacefast-settings.php index 06458e8..ee47676 100644 --- a/includes/class-spacefast-settings.php +++ b/includes/class-spacefast-settings.php @@ -7,69 +7,7 @@ final class Spacefast_Settings { const MODE_HEADLESS = 'headless'; const MODE_STATIC = 'static'; - /** - * Parse the one-time Dashboard connection. - * - * @return array{api_url:string,space_id:string,token:string} - */ - public static function parse_connection( string $json ): array { - $value = json_decode( $json, true ); - if ( ! is_array( $value ) ) { - throw new InvalidArgumentException( 'Connection must be valid JSON.' ); - } - - $api_url = isset( $value['apiUrl'] ) && is_string( $value['apiUrl'] ) - ? untrailingslashit( trim( $value['apiUrl'] ) ) - : ''; - $space_id = isset( $value['spaceId'] ) && is_string( $value['spaceId'] ) - ? trim( $value['spaceId'] ) - : ''; - $token = isset( $value['token'] ) && is_string( $value['token'] ) - ? trim( $value['token'] ) - : ''; - - if ( ! self::allowed_api_url( $api_url ) ) { - throw new InvalidArgumentException( 'API URL is not an official Spacefast API origin.' ); - } - if ( ! preg_match( '/^spc_[A-Za-z0-9_-]+$/', $space_id ) ) { - throw new InvalidArgumentException( 'Space ID is invalid.' ); - } - if ( ! preg_match( '/^sfa_[A-Za-z0-9_-]+$/', $token ) ) { - throw new InvalidArgumentException( 'Connection token is invalid.' ); - } - - return array( - 'api_url' => $api_url, - 'space_id' => $space_id, - 'token' => $token, - ); - } - - private static function allowed_api_url( string $api_url ): bool { - if ( - defined( 'SPACEFAST_WORDPRESS_API_URL' ) - && untrailingslashit( (string) SPACEFAST_WORDPRESS_API_URL ) === $api_url - ) { - return true; - } - $parts = wp_parse_url( $api_url ); - if ( - ! is_array( $parts ) - || 'https' !== ( $parts['scheme'] ?? '' ) - || isset( $parts['path'] ) - || isset( $parts['query'] ) - || isset( $parts['fragment'] ) - ) { - return false; - } - $host = strtolower( (string) ( $parts['host'] ?? '' ) ); - return 1 === preg_match( '/^api(?:-[a-z0-9-]+)?\\.spacefast\\.com$/', $host ) - || 'api.sf.localhost' === $host; - } - - /** - * @return array{api_url:string,space_id:string,token:string,mode:string} - */ + /** @return array */ public static function get(): array { $stored = get_option( self::OPTION, array() ); $stored = is_array( $stored ) ? $stored : array(); @@ -77,35 +15,39 @@ public static function get(): array { if ( ! in_array( $mode, self::modes(), true ) ) { $mode = self::MODE_HEADLESS; } - return array( - 'api_url' => defined( 'SPACEFAST_WORDPRESS_API_URL' ) - ? untrailingslashit( (string) SPACEFAST_WORDPRESS_API_URL ) - : (string) ( $stored['api_url'] ?? '' ), - 'space_id' => defined( 'SPACEFAST_WORDPRESS_SPACE_ID' ) - ? (string) SPACEFAST_WORDPRESS_SPACE_ID - : (string) ( $stored['space_id'] ?? '' ), - 'token' => defined( 'SPACEFAST_WORDPRESS_TOKEN' ) - ? (string) SPACEFAST_WORDPRESS_TOKEN - : (string) ( $stored['token'] ?? '' ), - 'mode' => $mode, + return array_merge( + array( + 'api_url' => self::api_url(), + 'mode' => $mode, + 'client_id' => '', + 'access_token' => '', + 'refresh_token' => '', + 'expires_at' => 0, + 'scope' => '', + 'team_id' => '', + 'team_name' => '', + 'team_slug' => '', + 'space_id' => '', + 'space_name' => '', + 'space_slug' => '', + 'live_url' => '', + 'verified_at' => 0, + ), + $stored, + array( 'api_url' => self::api_url(), 'mode' => $mode ) ); } - /** - * @param array{api_url:string,space_id:string,token:string} $connection Connection. - */ - public static function save( array $connection ): void { - $stored = get_option( self::OPTION, array() ); - $stored = is_array( $stored ) ? $stored : array(); - $mode = in_array( (string) ( $stored['mode'] ?? '' ), self::modes(), true ) - ? (string) $stored['mode'] - : self::MODE_HEADLESS; - update_option( - self::OPTION, - array_merge( $connection, array( 'mode' => $mode ) ), - false - ); - if ( false === get_option( Spacefast_Sync_State::OPTION, false ) ) { + public static function api_url(): string { + return defined( 'SPACEFAST_WORDPRESS_API_URL' ) + ? untrailingslashit( (string) SPACEFAST_WORDPRESS_API_URL ) + : 'https://api.spacefast.com'; + } + + /** @param array $values Values to merge. */ + public static function merge( array $values ): void { + update_option( self::OPTION, array_merge( self::get(), $values ), false ); + if ( class_exists( 'Spacefast_Sync_State' ) && false === get_option( Spacefast_Sync_State::OPTION, false ) ) { add_option( Spacefast_Sync_State::OPTION, Spacefast_Sync_State::defaults(), '', false ); } } @@ -114,31 +56,44 @@ public static function save_mode( string $mode ): void { if ( ! in_array( $mode, self::modes(), true ) ) { throw new InvalidArgumentException( 'Publishing mode is invalid.' ); } - $stored = get_option( self::OPTION, array() ); - $stored = is_array( $stored ) ? $stored : array(); - $stored['mode'] = $mode; - update_option( self::OPTION, $stored, false ); + self::merge( + array( + 'mode' => $mode, + 'verified_at' => 0, + ) + ); } - /** - * @return array - */ + /** @return array */ public static function modes(): array { return array( self::MODE_HEADLESS, self::MODE_STATIC ); } public static function mode(): string { - return self::get()['mode']; + return (string) self::get()['mode']; } - public static function disconnect(): void { - delete_option( self::OPTION ); - Spacefast_Sync_State::reset(); - Spacefast_Static_Publisher::reset(); + public static function authorized(): bool { + $value = self::get(); + return '' !== $value['client_id'] + && '' !== $value['access_token'] + && '' !== $value['refresh_token']; } public static function configured(): bool { $value = self::get(); - return '' !== $value['api_url'] && '' !== $value['space_id'] && '' !== $value['token']; + return self::authorized() + && '' !== $value['team_id'] + && '' !== $value['space_id'] + && 0 < (int) $value['verified_at']; + } + + public static function disconnect(): void { + delete_option( self::OPTION ); + delete_option( Spacefast_OAuth::PENDING_OPTION ); + delete_option( Spacefast_OAuth::CHOICES_OPTION ); + delete_option( 'spacefast_wordpress_snapshot_required' ); + Spacefast_Sync_State::reset(); + Spacefast_Static_Publisher::reset(); } } diff --git a/includes/class-spacefast-simply-static-task.php b/includes/class-spacefast-simply-static-task.php index 8664a6f..64931d4 100644 --- a/includes/class-spacefast-simply-static-task.php +++ b/includes/class-spacefast-simply-static-task.php @@ -18,6 +18,11 @@ public function perform() { || ! empty( get_option( 'simply-static-use-single' ) ) || ! empty( get_option( 'simply-static-use-build' ) ) || ! empty( get_option( 'simply-static-404-only' ) ); + if ( $partial_export && get_option( 'spacefast_wordpress_snapshot_required', false ) ) { + throw new RuntimeException( + __( 'Content was deleted. Run a full Export and publish so the old files are removed from Spacefast.', 'spacefast-wordpress' ) + ); + } $result = Spacefast_Static_Publisher::step( $options->get_archive_dir(), null, @@ -38,6 +43,9 @@ public function perform() { } Spacefast_Plugin::static_publish_completed( $result['version_id'], $result['status'] ); + if ( ! $partial_export ) { + delete_option( 'spacefast_wordpress_snapshot_required' ); + } $this->save_status_message( 'unchanged' === $result['status'] ? __( 'Spacefast is already up to date', 'spacefast-wordpress' ) diff --git a/includes/class-spacefast-sync-state.php b/includes/class-spacefast-sync-state.php index 0a45149..612c899 100644 --- a/includes/class-spacefast-sync-state.php +++ b/includes/class-spacefast-sync-state.php @@ -21,6 +21,8 @@ public static function defaults(): array { 'last_message' => '', 'last_build_id' => '', 'last_version_id' => '', + 'last_attempt_at' => 0, + 'last_success_at' => 0, ); } @@ -71,6 +73,7 @@ public static function acknowledge( array $state, int $generation, string $build $state['last_status'] = (int) $state['desired'] > $generation ? 'pending' : 'delivered'; $state['last_message'] = ''; $state['last_build_id'] = $build_id; + $state['last_success_at'] = time(); return $state; } @@ -86,6 +89,7 @@ public static function acknowledge_static( array $state, string $version_id, str $state['last_status'] = $status; $state['last_message'] = ''; $state['last_version_id'] = $version_id; + $state['last_success_at'] = time(); return $state; } diff --git a/readme.txt b/readme.txt index 62ab39f..92a7dcb 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: spacefast Tags: static site, headless cms, simply static, deployment Requires at least: 6.5 Requires PHP: 8.1 -Stable tag: 0.2.0 +Stable tag: 0.3.0 License: GPLv2 or later Publish a Simply Static export to Spacefast, or rebuild a headless site when WordPress content changes. @@ -19,18 +19,23 @@ Both modes include a manual action in Settings > Spacefast. Headless repository == Installation == -1. In Spacefast, configure the WordPress connection for the Space. -2. Download and activate this plugin. -3. Open Settings > Spacefast and paste the one-time connection. -4. Choose Static WordPress or Headless CMS and test the connection. -5. For Static WordPress, install and activate the free Simply Static plugin. +1. Download and activate this plugin. +2. Open Settings > Spacefast and choose Static WordPress or Headless CMS. +3. Continue to Spacefast, sign in, and authorize one Team. +4. Back in WordPress, choose the Space to publish or rebuild. +5. For Static WordPress, use the provided link to install or activate Simply Static. == Security == -The connection key is limited to one Space. It can publish versions, read version status, and trigger the connected production repository. It cannot change Space settings or access another Space. Secrets are stored in a non-autoloaded option and are never shown again. +OAuth access is limited to the Team you authorize and the mode you choose. WordPress then verifies the selected Space before showing Connected. Tokens are stored in non-autoloaded options and are never displayed. == Changelog == += 0.3.0 = +* Replace copied API keys with Team-scoped OAuth 2 authorization and Space selection in WordPress. +* Add refresh-token rotation and remote revocation on disconnect. +* Add one-click Simply Static setup, human delivery states, WP-Cron health, and deletion-safe static publishing. + = 0.2.0 = * Add direct Simply Static export publishing. * Add explicit Static WordPress and Headless CMS modes. diff --git a/spacefast-wordpress.php b/spacefast-wordpress.php index 45a68d8..34a8eb6 100644 --- a/spacefast-wordpress.php +++ b/spacefast-wordpress.php @@ -3,7 +3,8 @@ * Plugin Name: Spacefast * Plugin URI: https://spacefast.com/ * Description: Publishes static WordPress exports or rebuilds a headless Spacefast site. - * Version: 0.2.0 + * Version: 0.3.0 + * Update URI: https://github.com/spacefast/wordpress * Requires at least: 6.5 * Requires PHP: 8.1 * Author: Spacefast @@ -13,11 +14,12 @@ defined( 'ABSPATH' ) || exit; -define( 'SPACEFAST_WORDPRESS_VERSION', '0.2.0' ); +define( 'SPACEFAST_WORDPRESS_VERSION', '0.3.0' ); define( 'SPACEFAST_WORDPRESS_FILE', __FILE__ ); require_once __DIR__ . '/includes/class-spacefast-settings.php'; require_once __DIR__ . '/includes/class-spacefast-sync-state.php'; +require_once __DIR__ . '/includes/class-spacefast-oauth.php'; require_once __DIR__ . '/includes/class-spacefast-client.php'; require_once __DIR__ . '/includes/class-spacefast-static-publisher.php'; require_once __DIR__ . '/includes/class-spacefast-plugin.php'; diff --git a/tests/acceptance/compose.yml b/tests/acceptance/compose.yml new file mode 100644 index 0000000..baa9d91 --- /dev/null +++ b/tests/acceptance/compose.yml @@ -0,0 +1,50 @@ +services: + db: + image: mariadb:11.4 + environment: + MARIADB_DATABASE: wordpress + MARIADB_USER: wordpress + MARIADB_PASSWORD: wordpress + MARIADB_ROOT_PASSWORD: root + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 2s + timeout: 5s + retries: 30 + + wordpress: + image: wordpress:6.5-php8.1-apache + depends_on: + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DB_NAME: wordpress + volumes: + - wordpress_data:/var/www/html + - ../..:/var/www/html/wp-content/plugins/spacefast-wordpress:ro + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost/wp-login.php >/dev/null || exit 1"] + interval: 2s + timeout: 5s + retries: 30 + + wpcli: + image: wordpress:cli-php8.1 + depends_on: + wordpress: + condition: service_healthy + user: "33:33" + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DB_NAME: wordpress + volumes: + - wordpress_data:/var/www/html + - ../..:/var/www/html/wp-content/plugins/spacefast-wordpress:ro + +volumes: + wordpress_data: diff --git a/tests/acceptance/wordpress.php b/tests/acceptance/wordpress.php new file mode 100644 index 0000000..dabb1e1 --- /dev/null +++ b/tests/acceptance/wordpress.php @@ -0,0 +1,53 @@ + 'acceptance-client', + 'access_token' => 'acceptance-access', + 'refresh_token' => 'acceptance-refresh', + 'expires_at' => time() + 900, + ) +); +global $wpdb; +$autoload = $wpdb->get_var( + $wpdb->prepare( + "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", + Spacefast_Settings::OPTION + ) +); +spacefast_accept( ! in_array( $autoload, array( 'yes', 'on', 'auto-on' ), true ), 'OAuth tokens are autoloaded.' ); + +$plugin = get_plugin_data( WP_PLUGIN_DIR . '/spacefast-wordpress/spacefast-wordpress.php', false, false ); +spacefast_accept( '0.3.0' === $plugin['Version'], 'Unexpected plugin version.' ); +spacefast_accept( 'https://github.com/spacefast/wordpress' === $plugin['UpdateURI'], 'Update URI is missing.' ); + +Spacefast_Settings::disconnect(); +fwrite( STDOUT, "Spacefast WordPress container acceptance: PASS\n" ); diff --git a/tests/behavior.php b/tests/behavior.php index fd05630..7fe6901 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -4,6 +4,7 @@ define( 'ABSPATH', __DIR__ . '/' ); define( 'DAY_IN_SECONDS', 86400 ); +define( 'MINUTE_IN_SECONDS', 60 ); define( 'SPACEFAST_WORDPRESS_VERSION', '0.1.0-test' ); $GLOBALS['spacefast_options'] = array(); @@ -77,6 +78,15 @@ function get_post_type_object( string $post_type ) { function wp_generate_uuid4(): string { return '00000000-0000-4000-8000-000000000001'; } +function wp_generate_password( int $length ): string { + return str_repeat( 'a', $length ); +} +function admin_url( string $path = '' ): string { + return 'https://wp.example.test/wp-admin/' . ltrim( $path, '/' ); +} +function add_query_arg( array $args, string $url ): string { + return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ); +} function home_url(): string { return 'https://wp.example.test'; } @@ -127,6 +137,7 @@ public function query( array $prepared ): int { require_once dirname( __DIR__ ) . '/includes/class-spacefast-sync-state.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-settings.php'; +require_once dirname( __DIR__ ) . '/includes/class-spacefast-oauth.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-client.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-static-publisher.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-plugin.php'; @@ -144,27 +155,119 @@ function response( int $status, array $data = array() ): array { ); } -$connection = Spacefast_Settings::parse_connection( - '{"apiUrl":"https://api.spacefast.com/","spaceId":"spc_demo","token":"sfa_secret"}' -); -check( 'https://api.spacefast.com' === $connection['api_url'], 'normalizes API origin' ); -check( 'spc_demo' === $connection['space_id'], 'keeps exact space id' ); -try { - Spacefast_Settings::parse_connection( - '{"apiUrl":"http://api.spacefast.com","spaceId":"spc_demo","token":"sfa_secret"}' +function raw_response( int $status, array $data = array() ): array { + return array( + 'response' => array( 'code' => $status ), + 'body' => json_encode( $data ), ); - throw new RuntimeException( 'insecure API origin accepted' ); -} catch ( InvalidArgumentException $expected ) { - check( true, 'insecure API origin rejected' ); } -try { - Spacefast_Settings::parse_connection( - '{"apiUrl":"https://attacker.example","spaceId":"spc_demo","token":"sfa_secret"}' + +check( + array( 'teams:read', 'spaces:read', 'spaces:publish', 'offline_access' ) === Spacefast_OAuth::scopes( Spacefast_Settings::MODE_STATIC ), + 'static OAuth asks only for Team discovery, Space discovery, publishing, and refresh access' +); +check( + array( 'teams:read', 'spaces:read', 'builds:trigger', 'offline_access' ) === Spacefast_OAuth::scopes( Spacefast_Settings::MODE_HEADLESS ), + 'headless OAuth asks only for Team discovery, Space discovery, builds, and refresh access' +); +check( + strlen( Spacefast_OAuth::pkce_challenge( str_repeat( 'v', 64 ) ) ) === 43, + 'PKCE challenge uses an unpadded SHA-256 base64url value' +); + +$oauth_requests = array(); +$oauth_transport = static function ( string $url, array $args ) use ( &$oauth_requests ): array { + $oauth_requests[] = array( $url, $args ); + if ( str_ends_with( $url, '/oauth2/register' ) ) { + return raw_response( 201, array( 'client_id' => 'client_wordpress' ) ); + } + if ( str_ends_with( $url, '/oauth2/token' ) ) { + return raw_response( + 200, + array( + 'access_token' => 'oauth_access', + 'refresh_token' => 'oauth_refresh', + 'expires_in' => 900, + 'scope' => 'teams:read spaces:read builds:trigger offline_access', + ) + ); + } + if ( str_contains( $url, '/v1/teams' ) ) { + return response( 200, array( array( 'id' => 'team_demo', 'name' => 'Demo Team', 'slug' => 'demo-team' ) ) ); + } + return response( + 200, + array( + array( + 'id' => 'spc_demo', + 'teamId' => 'team_demo', + 'teamSlug' => 'demo-team', + 'slug' => 'demo-space', + 'title' => 'Demo Space', + 'liveUrl' => 'https://demo.spacefast.site', + ), + ) ); - throw new RuntimeException( 'arbitrary API origin accepted' ); -} catch ( InvalidArgumentException $expected ) { - check( true, 'arbitrary API origin rejected' ); -} +}; +$oauth = new Spacefast_OAuth( $oauth_transport ); +$authorization = $oauth->begin( Spacefast_Settings::MODE_HEADLESS ); +check( true === $authorization['ok'], 'dynamic client registration starts OAuth' ); +$registration_body = json_decode( $oauth_requests[0][1]['body'], true ); +check( 'none' === $registration_body['token_endpoint_auth_method'], 'registers a public PKCE client' ); +check( + array( Spacefast_Settings::api_url() . '/v1' ) === $registration_body['resources'], + 'binds tokens to the Spacefast API resource' +); +check( str_contains( (string) $authorization['url'], 'code_challenge_method=S256' ), 'authorization uses PKCE S256' ); +$pending = get_option( Spacefast_OAuth::PENDING_OPTION ); +$finished = $oauth->finish( 'authorization-code', (string) $pending['state'] ); +check( true === $finished['ok'], 'valid callback exchanges its code and loads Team-scoped choices' ); +check( 'oauth_refresh' === Spacefast_Settings::get()['refresh_token'], 'stores the rotating refresh token' ); +check( 1 === count( get_option( Spacefast_OAuth::CHOICES_OPTION )['spaces'] ), 'returns Space choices to WordPress' ); +check( ! Spacefast_Settings::configured(), 'authorization alone is not presented as Connected' ); + +Spacefast_Settings::merge( array( 'expires_at' => 1 ) ); +$refresh_requests = array(); +$refreshing_oauth = new Spacefast_OAuth( + static function ( string $url, array $args ) use ( &$refresh_requests ): array { + $refresh_requests[] = array( $url, $args ); + return raw_response( + 200, + array( + 'access_token' => 'rotated_access', + 'refresh_token' => 'rotated_refresh', + 'expires_in' => 900, + ) + ); + } +); +check( 'rotated_access' === $refreshing_oauth->access_token(), 'refreshes before an access token expires' ); +check( 'rotated_refresh' === Spacefast_Settings::get()['refresh_token'], 'persists refresh-token rotation' ); +$revoke_requests = array(); +( new Spacefast_OAuth( + static function ( string $url, array $args ) use ( &$revoke_requests ): array { + $revoke_requests[] = array( $url, $args ); + return raw_response( 200 ); + } +) )->revoke(); +check( 2 === count( $revoke_requests ), 'disconnect can revoke refresh and access tokens remotely' ); + +$connection = array( + 'mode' => Spacefast_Settings::MODE_HEADLESS, + 'client_id' => 'client_wordpress', + 'access_token' => 'access_secret', + 'refresh_token' => 'refresh_secret', + 'expires_at' => time() + 3600, + 'scope' => 'teams:read spaces:read builds:trigger offline_access', + 'team_id' => 'team_demo', + 'team_name' => 'Demo Team', + 'team_slug' => 'demo-team', + 'space_id' => 'spc_demo', + 'space_name' => 'Demo Space', + 'space_slug' => 'demo-space', + 'live_url' => 'https://demo.spacefast.site', + 'verified_at' => time(), +); $state = Spacefast_Sync_State::record_change( Spacefast_Sync_State::defaults(), @@ -257,7 +360,7 @@ function response( int $status, array $data = array() ): array { ); Spacefast_Settings::disconnect(); -Spacefast_Settings::save( $connection ); +Spacefast_Settings::merge( $connection ); $reconnected = Spacefast_Sync_State::mutate( static fn( array $current ): array => Spacefast_Sync_State::record_change( $current, @@ -289,7 +392,7 @@ function response( int $status, array $data = array() ): array { 'rebuilds after deleting a public REST-visible post' ); -Spacefast_Settings::save( $connection ); +Spacefast_Settings::merge( $connection ); $requests = array(); $transport = static function ( string $url, array $args ) use ( &$requests ) { $requests[] = array( $url, $args ); @@ -316,8 +419,8 @@ function response( int $status, array $data = array() ): array { 'uses the constrained server-side trigger' ); check( - 'Bearer sfa_secret' === $build_request['headers']['Authorization'], - 'authenticates with the exact scoped secret' + 'Bearer access_secret' === $build_request['headers']['Authorization'], + 'authenticates with the current OAuth access token' ); $mismatch = new Spacefast_Client( @@ -358,7 +461,13 @@ static function ( string $url ) use ( &$invalid_receipt_requests ): array { check( false === $invalid_receipt_result['ok'], 'rejects an empty success response' ); check( true === $invalid_receipt_result['retryable'], 'retries invalid build receipts' ); -Spacefast_Settings::save_mode( Spacefast_Settings::MODE_STATIC ); +Spacefast_Settings::merge( + array( + 'mode' => Spacefast_Settings::MODE_STATIC, + 'scope' => 'teams:read spaces:read spaces:publish offline_access', + 'verified_at' => time(), + ) +); check( 'static' === Spacefast_Settings::mode(), 'stores the selected static publishing mode' ); check( array( 'setup', 'fetch_urls', 'spacefast_publish', 'wrapup' ) === Spacefast_Plugin::simply_static_tasks( diff --git a/uninstall.php b/uninstall.php index 9fe552f..a3b0855 100644 --- a/uninstall.php +++ b/uninstall.php @@ -7,4 +7,7 @@ delete_option( 'spacefast_wordpress_sync_state' ); delete_option( 'spacefast_wordpress_worker_lock' ); delete_option( 'spacefast_wordpress_publish_state' ); +delete_option( 'spacefast_wordpress_oauth_pending' ); +delete_option( 'spacefast_wordpress_oauth_choices' ); +delete_option( 'spacefast_wordpress_snapshot_required' ); delete_transient( 'spacefast_wordpress_admin_notice' ); From 0356e3a3177caba22794520f8c7bef983fccc1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Fri, 7 Aug 2026 04:12:54 +0200 Subject: [PATCH 2/2] Polish WordPress connection management --- includes/class-spacefast-client.php | 38 +++++++++++++++++++-- includes/class-spacefast-oauth.php | 17 +++++++-- includes/class-spacefast-plugin.php | 45 ++++++++++++++++++++++-- tests/acceptance/compose.yml | 5 +-- tests/acceptance/wordpress.php | 6 ++++ tests/behavior.php | 53 ++++++++++++++++++++++++++--- 6 files changed, 150 insertions(+), 14 deletions(-) diff --git a/includes/class-spacefast-client.php b/includes/class-spacefast-client.php index 4884dc7..f6d6ca9 100644 --- a/includes/class-spacefast-client.php +++ b/includes/class-spacefast-client.php @@ -73,6 +73,9 @@ private function request( string $method, string $path, ?array $body = null, arr 'code' => 'ok', 'message' => '', 'data' => $data, + 'pagination' => isset( $decoded['pagination'] ) && is_array( $decoded['pagination'] ) + ? $decoded['pagination'] + : array(), ); } @@ -95,12 +98,43 @@ private function request( string $method, string $path, ?array $body = null, arr /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ public function list_teams(): array { - return $this->request( 'GET', '/v1/teams?limit=100' ); + return $this->list_all( '/v1/teams' ); } /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ public function list_spaces(): array { - return $this->request( 'GET', '/v1/spaces?limit=100' ); + return $this->list_all( '/v1/spaces' ); + } + + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ + private function list_all( string $path ): array { + $items = array(); + $cursor = ''; + for ( $page = 0; $page < 100; $page++ ) { + $query = $path . '?limit=100'; + if ( '' !== $cursor ) { + $query .= '&cursor=' . rawurlencode( $cursor ); + } + $result = $this->request( 'GET', $query ); + if ( ! $result['ok'] ) { + return $result; + } + $items = array_merge( $items, array_values( $result['data'] ) ); + $pagination = is_array( $result['pagination'] ?? null ) ? $result['pagination'] : array(); + $next_cursor = (string) ( $pagination['nextCursor'] ?? '' ); + if ( true !== ( $pagination['hasMore'] ?? false ) || '' === $next_cursor ) { + $result['data'] = $items; + return $result; + } + $cursor = $next_cursor; + } + return array( + 'ok' => false, + 'retryable' => false, + 'code' => 'pagination_limit', + 'message' => 'Spacefast returned too many pages while loading choices.', + 'data' => array(), + ); } /** diff --git a/includes/class-spacefast-oauth.php b/includes/class-spacefast-oauth.php index 4509a63..01badd3 100644 --- a/includes/class-spacefast-oauth.php +++ b/includes/class-spacefast-oauth.php @@ -35,6 +35,14 @@ public static function pkce_challenge( string $verifier ): string { return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); } + public static function client_name(): string { + $parts = wp_parse_url( home_url() ); + $host = is_array( $parts ) ? (string) ( $parts['host'] ?? '' ) : ''; + return '' === $host + ? 'Spacefast for WordPress' + : 'Spacefast for WordPress (' . substr( $host, 0, 120 ) . ')'; + } + /** @return array{ok:bool,message:string,url?:string} */ public function begin( string $mode ): array { if ( ! in_array( $mode, Spacefast_Settings::modes(), true ) ) { @@ -48,7 +56,7 @@ public function begin( string $mode ): array { 'POST', $api_url . '/v1/auth/oauth2/register', array( - 'client_name' => 'Spacefast for WordPress', + 'client_name' => self::client_name(), 'client_uri' => 'https://github.com/spacefast/wordpress', 'redirect_uris' => array( $callback ), 'grant_types' => array( 'authorization_code', 'refresh_token' ), @@ -99,6 +107,7 @@ public function finish( string $code, string $state ): array { if ( ! hash_equals( (string) ( $pending['state'] ?? '' ), $state ) || '' === $code ) { return array( 'ok' => false, 'message' => 'Authorization could not be verified. Start again.' ); } + $previous = Spacefast_Settings::get(); $tokens = $this->form_request( Spacefast_Settings::api_url() . '/v1/auth/oauth2/token', array( @@ -115,6 +124,7 @@ public function finish( string $code, string $state ): array { if ( empty( $data['access_token'] ) || empty( $data['refresh_token'] ) ) { return array( 'ok' => false, 'message' => 'Spacefast returned an incomplete authorization.' ); } + $this->revoke( $previous ); Spacefast_Settings::merge( array( 'mode' => (string) $pending['mode'], @@ -182,8 +192,9 @@ public function access_token(): string { return (string) $settings['access_token']; } - public function revoke(): void { - $settings = Spacefast_Settings::get(); + /** @param array|null $settings Settings whose tokens should be revoked. */ + public function revoke( ?array $settings = null ): void { + $settings = $settings ?? Spacefast_Settings::get(); foreach ( array( $settings['refresh_token'], $settings['access_token'] ) as $token ) { if ( ! is_string( $token ) || '' === $token ) continue; $this->form_request( diff --git a/includes/class-spacefast-plugin.php b/includes/class-spacefast-plugin.php index 34354d0..9562d29 100644 --- a/includes/class-spacefast-plugin.php +++ b/includes/class-spacefast-plugin.php @@ -14,6 +14,7 @@ public static function boot(): void { add_action( 'admin_init', array( __CLASS__, 'oauth_callback' ) ); add_action( 'admin_post_spacefast_wordpress_oauth_start', array( __CLASS__, 'oauth_start' ) ); add_action( 'admin_post_spacefast_wordpress_select_space', array( __CLASS__, 'select_space' ) ); + add_action( 'admin_post_spacefast_wordpress_change_space', array( __CLASS__, 'change_space' ) ); add_action( 'admin_post_spacefast_wordpress_disconnect', array( __CLASS__, 'disconnect' ) ); add_action( 'admin_post_spacefast_wordpress_test', array( __CLASS__, 'test_connection' ) ); add_action( 'admin_post_spacefast_wordpress_build', array( __CLASS__, 'manual_build' ) ); @@ -93,7 +94,6 @@ public static function oauth_start(): void { self::assert_admin( 'spacefast_wordpress_oauth_start' ); $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : ''; try { - Spacefast_Settings::save_mode( $mode ); $result = ( new Spacefast_OAuth() )->begin( $mode ); if ( ! $result['ok'] ) throw new RuntimeException( $result['message'] ); wp_redirect( esc_url_raw( (string) $result['url'] ) ); @@ -173,6 +173,29 @@ public static function select_space(): void { self::redirect(); } + public static function change_space(): void { + self::assert_admin( 'spacefast_wordpress_change_space' ); + $result = ( new Spacefast_OAuth() )->load_choices(); + if ( $result['ok'] ) { + Spacefast_Settings::merge( + array( + 'space_id' => '', + 'space_name' => '', + 'space_slug' => '', + 'live_url' => '', + 'verified_at' => 0, + ) + ); + } + self::notice( + $result['ok'] ? 'success' : 'error', + $result['ok'] + ? __( 'Choose the new Space for this WordPress site.', 'spacefast-wordpress' ) + : $result['message'] + ); + self::redirect(); + } + public static function disconnect(): void { self::assert_admin( 'spacefast_wordpress_disconnect' ); ( new Spacefast_OAuth() )->revoke(); @@ -615,7 +638,7 @@ public static function render_admin(): void {

— /

-

.

+

.

@@ -636,6 +659,13 @@ public static function render_admin(): void { +
+
+ + + +
+

@@ -663,4 +693,15 @@ private static function action_form( string $action, string $label, string $clas + + + + + + + array( 'code' => 200 ), + 'body' => json_encode( + array( + 'data' => $data, + 'pagination' => array( + 'nextCursor' => $next_cursor, + 'hasMore' => null !== $next_cursor, + ), + ) + ), + ); +} + function raw_response( int $status, array $data = array() ): array { return array( 'response' => array( 'code' => $status ), @@ -193,10 +208,23 @@ function raw_response( int $status, array $data = array() ): array { ); } if ( str_contains( $url, '/v1/teams' ) ) { - return response( 200, array( array( 'id' => 'team_demo', 'name' => 'Demo Team', 'slug' => 'demo-team' ) ) ); + return paginated_response( array( array( 'id' => 'team_demo', 'name' => 'Demo Team', 'slug' => 'demo-team' ) ) ); + } + if ( str_contains( $url, 'cursor=space%20cursor%2F2' ) ) { + return paginated_response( + array( + array( + 'id' => 'spc_second', + 'teamId' => 'team_demo', + 'teamSlug' => 'demo-team', + 'slug' => 'second-space', + 'title' => 'Second Space', + 'liveUrl' => 'https://second.spacefast.site', + ), + ) + ); } - return response( - 200, + return paginated_response( array( array( 'id' => 'spc_demo', @@ -206,7 +234,8 @@ function raw_response( int $status, array $data = array() ): array { 'title' => 'Demo Space', 'liveUrl' => 'https://demo.spacefast.site', ), - ) + ), + 'space cursor/2' ); }; $oauth = new Spacefast_OAuth( $oauth_transport ); @@ -214,6 +243,7 @@ function raw_response( int $status, array $data = array() ): array { check( true === $authorization['ok'], 'dynamic client registration starts OAuth' ); $registration_body = json_decode( $oauth_requests[0][1]['body'], true ); check( 'none' === $registration_body['token_endpoint_auth_method'], 'registers a public PKCE client' ); +check( 'Spacefast for WordPress (wp.example.test)' === $registration_body['client_name'], 'identifies this WordPress installation in Connected Apps' ); check( array( Spacefast_Settings::api_url() . '/v1' ) === $registration_body['resources'], 'binds tokens to the Spacefast API resource' @@ -223,9 +253,22 @@ function raw_response( int $status, array $data = array() ): array { $finished = $oauth->finish( 'authorization-code', (string) $pending['state'] ); check( true === $finished['ok'], 'valid callback exchanges its code and loads Team-scoped choices' ); check( 'oauth_refresh' === Spacefast_Settings::get()['refresh_token'], 'stores the rotating refresh token' ); -check( 1 === count( get_option( Spacefast_OAuth::CHOICES_OPTION )['spaces'] ), 'returns Space choices to WordPress' ); +check( 2 === count( get_option( Spacefast_OAuth::CHOICES_OPTION )['spaces'] ), 'loads every paginated Space choice into WordPress' ); +check( + str_contains( implode( ' ', array_column( $oauth_requests, 0 ) ), 'cursor=space%20cursor%2F2' ), + 'encodes and follows the opaque Space cursor' +); check( ! Spacefast_Settings::configured(), 'authorization alone is not presented as Connected' ); +$reauthorization = $oauth->begin( Spacefast_Settings::MODE_HEADLESS ); +$reauthorization_pending = get_option( Spacefast_OAuth::PENDING_OPTION ); +$reauthorized = $oauth->finish( 'replacement-code', (string) $reauthorization_pending['state'] ); +check( true === $reauthorized['ok'], 'reauthorization replaces a connection only after the callback succeeds' ); +check( + 2 === count( array_filter( $oauth_requests, static fn( array $request ): bool => str_ends_with( $request[0], '/oauth2/revoke' ) ) ), + 'reauthorization revokes the replaced refresh and access tokens' +); + Spacefast_Settings::merge( array( 'expires_at' => 1 ) ); $refresh_requests = array(); $refreshing_oauth = new Spacefast_OAuth(