From 69b2481299f1ccccdb4ac655cfe49dd06fb09763 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Sat, 15 Aug 2026 23:17:16 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=202=20=E2=80=94=20WordPress=20pl?= =?UTF-8?q?ugin=20(auth/api/site-info/admin)=20+=20web=20SiteConnector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugin: Wursor_Auth (token hashing, HMAC, scoped tokens), Wursor_API (REST + auth), Wursor_Site_Info (builder/capabilities/preflight), Wursor_Admin - plugin: test-auth.php (10 auth tests, run in a WP+PHP env) - web: SiteConnector pairing UI (code + poll + states) - api: PluginClient signs full REST route (matches WP get_route) - 92 unit tests green --- api/__tests__/services/plugin-client.test.ts | 2 +- api/src/services/plugin-client.ts | 12 ++- plugin/__tests__/.gitkeep | 0 plugin/__tests__/test-auth.php | 81 ++++++++++++++ plugin/src/.gitkeep | 0 plugin/src/class-admin.php | 65 ++++++++++++ plugin/src/class-api.php | 74 +++++++++++++ plugin/src/class-auth.php | 79 ++++++++++++++ plugin/src/class-site-info.php | 105 +++++++++++++++++++ plugin/wursor.php | 19 ++++ web/__tests__/SiteConnector.test.tsx | 31 ++++++ web/src/components/SiteConnector.tsx | 51 +++++++++ 12 files changed, 515 insertions(+), 4 deletions(-) delete mode 100644 plugin/__tests__/.gitkeep create mode 100644 plugin/__tests__/test-auth.php delete mode 100644 plugin/src/.gitkeep create mode 100644 plugin/src/class-admin.php create mode 100644 plugin/src/class-api.php create mode 100644 plugin/src/class-auth.php create mode 100644 plugin/src/class-site-info.php create mode 100644 plugin/wursor.php create mode 100644 web/__tests__/SiteConnector.test.tsx create mode 100644 web/src/components/SiteConnector.tsx diff --git a/api/__tests__/services/plugin-client.test.ts b/api/__tests__/services/plugin-client.test.ts index ae261d4..258d0c8 100644 --- a/api/__tests__/services/plugin-client.test.ts +++ b/api/__tests__/services/plugin-client.test.ts @@ -43,7 +43,7 @@ describe('PluginClient', () => { const [, init] = fetchMock.mock.calls[0] as [string, { headers: Record }]; const ts = init.headers['X-Wursor-Timestamp']; - expect(init.headers['X-Wursor-Signature']).toBe(expectedSignature('h-secret', ts, 'POST', '/files', '{"a":1}')); + expect(init.headers['X-Wursor-Signature']).toBe(expectedSignature('h-secret', ts, 'POST', '/wursor/v1/files', '{"a":1}')); }); it('maps a 401 to Authentication failed', async () => { diff --git a/api/src/services/plugin-client.ts b/api/src/services/plugin-client.ts index 1ef8a79..9fc6db8 100644 --- a/api/src/services/plugin-client.ts +++ b/api/src/services/plugin-client.ts @@ -14,12 +14,17 @@ function sha256hex(value: string): string { export class PluginClient { private readonly baseUrl: string; + private readonly namespace: string; - constructor(private readonly creds: PluginCredentials) { + constructor( + private readonly creds: PluginCredentials, + namespace = 'wursor/v1', + ) { if (!isHttpsUrl(creds.siteUrl)) { throw new Error('Site URL must be https'); } - this.baseUrl = `${creds.siteUrl.replace(/\/$/, '')}/wp-json/wursor/v1`; + this.namespace = namespace; + this.baseUrl = `${creds.siteUrl.replace(/\/$/, '')}/wp-json/${namespace}`; } async get(path: string): Promise { @@ -32,8 +37,9 @@ export class PluginClient { private async request(method: string, path: string, body?: unknown): Promise { const bodyText = body === undefined ? '' : JSON.stringify(body); + const route = `/${this.namespace}${path}`; const timestamp = String(Math.floor(Date.now() / 1000)); - const canonical = `${timestamp}\n${method}\n${path}\n${sha256hex(bodyText)}`; + const canonical = `${timestamp}\n${method}\n${route}\n${sha256hex(bodyText)}`; const signature = createHmac('sha256', this.creds.hmacSecret).update(canonical).digest('hex'); const token = method === 'GET' ? this.creds.readToken : (this.creds.deployToken ?? this.creds.readToken); diff --git a/plugin/__tests__/.gitkeep b/plugin/__tests__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugin/__tests__/test-auth.php b/plugin/__tests__/test-auth.php new file mode 100644 index 0000000..e911dd0 --- /dev/null +++ b/plugin/__tests__/test-auth.php @@ -0,0 +1,81 @@ +assertNotEquals( 'read-token', get_option( Wursor_Auth::OPTION_READ_HASH ) ); + $this->assertEquals( hash( 'sha256', 'read-token' ), get_option( Wursor_Auth::OPTION_READ_HASH ) ); + $this->assertNotEquals( 'hmac-secret', get_option( Wursor_Auth::OPTION_HMAC_SECRET ) ); + } + + public function test_verify_token_accepts_matching_token() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + $this->assertTrue( Wursor_Auth::verify_token( 'read-token', 'read' ) ); + $this->assertTrue( Wursor_Auth::verify_token( 'deploy-token', 'deploy' ) ); + } + + public function test_verify_token_rejects_wrong_token() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + $this->assertFalse( Wursor_Auth::verify_token( 'wrong', 'read' ) ); + } + + public function test_read_token_does_not_verify_as_deploy() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + $this->assertFalse( Wursor_Auth::verify_token( 'read-token', 'deploy' ) ); + } + + public function test_hmac_accepts_valid_signature() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + $ts = (string) time(); + $route = '/wursor/v1/site-info'; + $body = ''; + $this->assertTrue( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) ); + } + + public function test_hmac_rejects_stale_timestamp() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + $ts = (string) ( time() - 120 ); + $route = '/wursor/v1/site-info'; + $body = ''; + $this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) ); + } + + public function test_hmac_rejects_tampered_body() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + $ts = (string) time(); + $route = '/wursor/v1/files'; + $sig = $this->sign( 'hmac-secret', $ts, 'POST', $route, '{"a":1}' ); + $this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'POST', $route, '{"a":2}', $sig ) ); + } + + public function test_hmac_rejects_missing_signature() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + $this->assertFalse( Wursor_Auth::verify_hmac( (string) time(), 'GET', '/wursor/v1/site-info', '', null ) ); + } + + public function test_rotated_tokens_invalidate_old_hashes() { + Wursor_Auth::store_tokens( 'old-read', 'old-deploy', 'old-secret' ); + Wursor_Auth::store_tokens( 'new-read', 'new-deploy', 'new-secret' ); + + $this->assertFalse( Wursor_Auth::verify_token( 'old-read', 'read' ) ); + $this->assertTrue( Wursor_Auth::verify_token( 'new-read', 'read' ) ); + } + + public function test_disconnect_clears_tokens() { + Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' ); + Wursor_Auth::clear_tokens(); + $this->assertFalse( Wursor_Auth::is_connected() ); + } +} diff --git a/plugin/src/.gitkeep b/plugin/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugin/src/class-admin.php b/plugin/src/class-admin.php new file mode 100644 index 0000000..8377c64 --- /dev/null +++ b/plugin/src/class-admin.php @@ -0,0 +1,65 @@ + +
+

Wursor

+ +

This site is connected to Wursor.

+
+ + +
+ +

Paste the pairing code from Wursor to connect this site.

+
+ + +
+ +
+ wp_json_encode( array( 'code' => $code, 'siteUrl' => home_url( '/' ) ) ), + 'headers' => array( 'Content-Type' => 'application/json' ), + 'timeout' => 15, + ) + ); + + if ( is_wp_error( $response ) ) { + return; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( isset( $body['readToken'], $body['deployToken'], $body['hmacSecret'] ) ) { + Wursor_Auth::store_tokens( $body['readToken'], $body['deployToken'], $body['hmacSecret'] ); + } + } +} diff --git a/plugin/src/class-api.php b/plugin/src/class-api.php new file mode 100644 index 0000000..4080142 --- /dev/null +++ b/plugin/src/class-api.php @@ -0,0 +1,74 @@ + 'GET', + 'callback' => array( __CLASS__, 'get_site_info' ), + 'permission_callback' => array( __CLASS__, 'authorize_read' ), + ) ); + register_rest_route( 'wursor/v1', '/files', array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'stub' ), + 'permission_callback' => array( __CLASS__, 'authorize_deploy' ), + ) ); + register_rest_route( 'wursor/v1', '/db', array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'stub' ), + 'permission_callback' => array( __CLASS__, 'authorize_deploy' ), + ) ); + register_rest_route( 'wursor/v1', '/wp-cli', array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'stub' ), + 'permission_callback' => array( __CLASS__, 'authorize_deploy' ), + ) ); + } + + public static function authorize_read( WP_REST_Request $request ) { + return self::authorize( $request, 'read' ); + } + + public static function authorize_deploy( WP_REST_Request $request ) { + return self::authorize( $request, 'deploy' ); + } + + private static function authorize( WP_REST_Request $request, $scope ) { + $auth = $request->get_header( 'authorization' ); + if ( ! is_string( $auth ) || 0 !== strpos( $auth, 'Bearer ' ) ) { + return new WP_Error( 'wursor_unauthorized', 'Missing bearer token', array( 'status' => 401 ) ); + } + $token = substr( $auth, 7 ); + + if ( 'deploy' === $scope ) { + if ( ! Wursor_Auth::verify_token( $token, 'deploy' ) ) { + return new WP_Error( 'wursor_forbidden', 'Deploy token required', array( 'status' => 403 ) ); + } + } elseif ( ! Wursor_Auth::verify_token( $token, 'read' ) && ! Wursor_Auth::verify_token( $token, 'deploy' ) ) { + return new WP_Error( 'wursor_unauthorized', 'Invalid token', array( 'status' => 401 ) ); + } + + $timestamp = $request->get_header( 'x-wursor-timestamp' ); + $signature = $request->get_header( 'x-wursor-signature' ); + $route = $request->get_route(); + $body = $request->get_body(); + + if ( ! Wursor_Auth::verify_hmac( $timestamp, $request->get_method(), $route, $body, $signature ) ) { + return new WP_Error( 'wursor_bad_signature', 'Bad HMAC signature', array( 'status' => 401 ) ); + } + + return true; + } + + public static function get_site_info( WP_REST_Request $request ) { + return Wursor_Site_Info::get_site_info(); + } + + public static function stub( WP_REST_Request $request ) { + return new WP_Error( 'wursor_not_implemented', 'Not implemented until Sprint 6', array( 'status' => 501 ) ); + } +} diff --git a/plugin/src/class-auth.php b/plugin/src/class-auth.php new file mode 100644 index 0000000..f861938 --- /dev/null +++ b/plugin/src/class-auth.php @@ -0,0 +1,79 @@ + self::MAX_SKEW_SECONDS ) { + return false; + } + $canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body ); + $expected = hash_hmac( 'sha256', $canonical, self::hmac_secret() ); + return hash_equals( $expected, $signature ); + } + + private static function hmac_secret() { + $encrypted = get_option( self::OPTION_HMAC_SECRET ); + return false === $encrypted ? '' : self::decrypt( $encrypted ); + } + + private static function encryption_key() { + return hash( 'sha256', wp_salt( 'auth' ) . wp_salt( 'auth_salt' ) ); + } + + private static function encrypt( $value ) { + $iv = random_bytes( 16 ); + $tag = ''; + $ciphertext = openssl_encrypt( $value, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag ); + if ( false === $ciphertext ) { + return false; + } + return base64_encode( $iv . $tag . $ciphertext ); + } + + private static function decrypt( $value ) { + $data = base64_decode( $value, true ); + if ( false === $data || strlen( $data ) < 32 ) { + return ''; + } + $iv = substr( $data, 0, 16 ); + $tag = substr( $data, 16, 16 ); + $ciphertext = substr( $data, 32 ); + $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag ); + return false === $plaintext ? '' : $plaintext; + } +} diff --git a/plugin/src/class-site-info.php b/plugin/src/class-site-info.php new file mode 100644 index 0000000..95a054c --- /dev/null +++ b/plugin/src/class-site-info.php @@ -0,0 +1,105 @@ + $theme->get_stylesheet(), + 'plugins' => self::plugins(), + 'wordpress_version' => get_bloginfo( 'version' ), + 'php_version' => PHP_VERSION, + 'builder' => self::detect_builder(), + 'capabilities' => self::capabilities(), + 'preflight' => self::preflight(), + ); + } + + private static function plugins() { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $all = get_plugins(); + $active = (array) get_option( 'active_plugins', array() ); + $result = array(); + foreach ( $all as $plugin_file => $data ) { + $result[] = array( + 'slug' => self::slug_from_file( $plugin_file ), + 'active' => in_array( $plugin_file, $active, true ), + ); + } + return $result; + } + + private static function active_slugs() { + $active = (array) get_option( 'active_plugins', array() ); + return array_map( array( __CLASS__, 'slug_from_file' ), $active ); + } + + private static function slug_from_file( $file ) { + $dir = dirname( $file ); + return '.' === $dir ? basename( $file, '.php' ) : $dir; + } + + private static function front_page_id() { + $front = (int) get_option( 'page_on_front' ); + if ( $front > 0 ) { + return $front; + } + $pages = get_pages( array( 'number' => 1 ) ); + return empty( $pages ) ? 0 : $pages[0]->ID; + } + + private static function front_page_content() { + $id = self::front_page_id(); + return $id > 0 ? (string) get_post_field( 'post_content', $id ) : ''; + } + + private static function detect_builder() { + $theme = wp_get_theme()->get_stylesheet(); + $active = self::active_slugs(); + $id = self::front_page_id(); + $content = self::front_page_content(); + + $elementor_mode = $id > 0 ? get_post_meta( $id, '_elementor_edit_mode', true ) : ''; + $elementor_data = $id > 0 ? get_post_meta( $id, '_elementor_data', true ) : ''; + $fl_builder = $id > 0 ? get_post_meta( $id, '_fl_builder_data', true ) : ''; + $et_pb = $id > 0 ? get_post_meta( $id, '_et_pb_use_builder', true ) : ''; + + if ( in_array( 'elementor', $active, true ) && ( '' !== $elementor_mode || '' !== $elementor_data ) ) { + return 'elementor'; + } + if ( in_array( 'beaver-builder-lite-version', $active, true ) && '' !== $fl_builder ) { + return 'beaver'; + } + if ( false !== stripos( $theme, 'divi' ) && 'on' === $et_pb ) { + return 'divi'; + } + if ( false !== strpos( $content, '