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..f6d6ca9 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, ), @@ -63,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(), ); } @@ -83,6 +96,47 @@ 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->list_all( '/v1/teams' ); + } + + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ + public function list_spaces(): array { + 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(), + ); + } + /** * @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..01badd3 --- /dev/null +++ b/includes/class-spacefast-oauth.php @@ -0,0 +1,234 @@ +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 ) ), '+/', '-_' ), '=' ); + } + + 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 ) ) { + 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' => self::client_name(), + '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.' ); + } + $previous = Spacefast_Settings::get(); + $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.' ); + } + $this->revoke( $previous ); + 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']; + } + + /** @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( + $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..9562d29 100644 --- a/includes/class-spacefast-plugin.php +++ b/includes/class-spacefast-plugin.php @@ -11,9 +11,11 @@ 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_change_space', array( __CLASS__, 'change_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 +26,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 +90,117 @@ 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 ) { + $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(); } + } + + 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 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 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 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 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(); + Spacefast_Settings::disconnect(); + self::notice( 'success', __( 'Spacefast disconnected and its refresh access was revoked.', 'spacefast-wordpress' ) ); self::redirect(); } @@ -192,10 +274,17 @@ 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 { self::record_change( 'taxonomy' ); } @@ -263,6 +352,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 +402,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 +504,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 +590,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 +602,53 @@ public static function render_admin(): void {

- -

-

+ +

- - - - - -
- -

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

+

+ + +

+

+ + +
+ + +
+ + +
+ +

+ + + +

— /

+

.

- + - +
+

-

+

@@ -491,12 +659,31 @@ 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 { ?>
@@ -506,4 +693,15 @@ private static function action_form( string $action, string $label, string $clas
+
+ + + + +
+ $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..c9b92bb --- /dev/null +++ b/tests/acceptance/compose.yml @@ -0,0 +1,51 @@ +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@sha256:deb7e5d058e277be30e7f7b93e8cf863b54130fcb2adf481a46280d76499d144 + depends_on: + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DB_NAME: wordpress + WORDPRESS_CONFIG_EXTRA: "define( 'WP_AUTO_UPDATE_CORE', false );" + 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@sha256:ab5fb76caa861f32c21e1d95a057f52007f4af7130fb16a0f68874dabe0549a4 + 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..7fb815a --- /dev/null +++ b/tests/acceptance/wordpress.php @@ -0,0 +1,59 @@ + '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..5b4ea27 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,28 +155,163 @@ 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 paginated_response( array $data, ?string $next_cursor = null ): array { + return array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( + array( + 'data' => $data, + 'pagination' => array( + 'nextCursor' => $next_cursor, + 'hasMore' => null !== $next_cursor, + ), + ) + ), ); - 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"}' + +function raw_response( int $status, array $data = array() ): array { + return array( + 'response' => array( 'code' => $status ), + 'body' => json_encode( $data ), ); - throw new RuntimeException( 'arbitrary API origin accepted' ); -} catch ( InvalidArgumentException $expected ) { - check( true, 'arbitrary API origin rejected' ); } +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 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 paginated_response( + array( + array( + 'id' => 'spc_demo', + 'teamId' => 'team_demo', + 'teamSlug' => 'demo-team', + 'slug' => 'demo-space', + 'title' => 'Demo Space', + 'liveUrl' => 'https://demo.spacefast.site', + ), + ), + 'space cursor/2' + ); +}; +$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( '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' +); +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( 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( + 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(), 'Post Published', @@ -257,7 +403,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 +435,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 +462,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 +504,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' );