From ef4e7e1469b4d47a88eb27357006cd96dee6808c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 01:35:08 +0000 Subject: [PATCH 01/48] Add the patternbuilderwp.com cloud module (Pattern Builder 2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect to patternbuilderwp.com from Appearance → Pattern Builder via OAuth (PKCE); browse My Cloud Library and the public Pattern Directory with live iframe previews; upload local patterns (theme or user, images bundled) with update-vs-new handling via a site link map; download any cloud pattern as a user or theme pattern with assets imported and markup re-sanitized. All cloud traffic flows through site-side proxy endpoints — the token never reaches the browser. New PHP components: Pattern_Builder_Cloud (token store + PKCE + HTTP), Pattern_Builder_Cloud_Porter (local ↔ PBP conversion), and Pattern_Builder_Cloud_Controller (the /cloud/* proxy REST routes). New JS: src/cloud/CloudBrowser.js wired into the browse app's rail. Includes porter unit tests; wp-phpunit bumped to ^7.1 to match current WordPress in the test environment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014jnurSGDDL9SPMcptRgB8U --- composer.json | 2 +- composer.lock | 74 +- ...class-pattern-builder-cloud-controller.php | 332 +++++++ .../class-pattern-builder-cloud-porter.php | 417 +++++++++ includes/class-pattern-builder-cloud.php | 348 ++++++++ includes/class-pattern-builder.php | 4 + package-lock.json | 4 +- src/admin/PatternBrowser.js | 153 ++-- src/cloud/CloudBrowser.js | 824 ++++++++++++++++++ src/cloud/cloud.scss | 164 ++++ tests/php/test-cloud-porter.php | 217 +++++ 11 files changed, 2420 insertions(+), 119 deletions(-) create mode 100644 includes/class-pattern-builder-cloud-controller.php create mode 100644 includes/class-pattern-builder-cloud-porter.php create mode 100644 includes/class-pattern-builder-cloud.php create mode 100644 src/cloud/CloudBrowser.js create mode 100644 src/cloud/cloud.scss create mode 100644 tests/php/test-cloud-porter.php diff --git a/composer.json b/composer.json index 1984cf7..30bd3ab 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "twentybellows/pattern-builder", "require-dev": { "phpunit/phpunit": "^9.3", - "wp-phpunit/wp-phpunit": "^6.6", + "wp-phpunit/wp-phpunit": "^7.1", "yoast/phpunit-polyfills": "^1.1", "spatie/phpunit-watcher": "^1.23", "squizlabs/php_codesniffer": "^3.7", diff --git a/composer.lock b/composer.lock index c9deafa..d7562b4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,62 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4bec48dccfe2c300837fc8be56042779", - "packages": [ - { - "name": "freemius/wordpress-sdk", - "version": "2.12.0", - "source": { - "type": "git", - "url": "https://github.com/Freemius/wordpress-sdk.git", - "reference": "db6f35a2b3d318a53330409dbeab49156ee76dd8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Freemius/wordpress-sdk/zipball/db6f35a2b3d318a53330409dbeab49156ee76dd8", - "reference": "db6f35a2b3d318a53330409dbeab49156ee76dd8", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "phpcompatibility/php-compatibility": "^9.3", - "phpcompatibility/phpcompatibility-wp": "^2.1", - "phpstan/extension-installer": "^1.3", - "squizlabs/php_codesniffer": "^3.7", - "szepeviktor/phpstan-wordpress": "^1.3", - "wp-coding-standards/wpcs": "^2.3" - }, - "type": "library", - "autoload": { - "files": [ - "start.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-3.0-only" - ], - "description": "Freemius WordPress SDK", - "homepage": "https://freemius.com", - "keywords": [ - "freemius", - "plugin", - "sdk", - "theme", - "wordpress", - "wordpress-plugin", - "wordpress-theme" - ], - "support": { - "issues": "https://github.com/Freemius/wordpress-sdk/issues", - "source": "https://github.com/Freemius/wordpress-sdk/tree/2.12.0" - }, - "time": "2025-05-11T07:07:09+00:00" - } - ], + "content-hash": "503853a419bc2fe8a91564cf5a2f6ecd", + "packages": [], "packages-dev": [ { "name": "clue/stdio-react", @@ -3772,16 +3718,16 @@ }, { "name": "wp-phpunit/wp-phpunit", - "version": "6.8.1", + "version": "7.1.0", "source": { "type": "git", "url": "https://github.com/wp-phpunit/wp-phpunit.git", - "reference": "a33d328dab5a4a9ddf0c560bcadbabb58b5ee67f" + "reference": "797edc710afc958854852d859c93d9aa1de6447d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/a33d328dab5a4a9ddf0c560bcadbabb58b5ee67f", - "reference": "a33d328dab5a4a9ddf0c560bcadbabb58b5ee67f", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/797edc710afc958854852d859c93d9aa1de6447d", + "reference": "797edc710afc958854852d859c93d9aa1de6447d", "shasum": "" }, "type": "library", @@ -3816,7 +3762,7 @@ "issues": "https://github.com/wp-phpunit/issues", "source": "https://github.com/wp-phpunit/wp-phpunit" }, - "time": "2025-04-16T01:40:54+00:00" + "time": "2026-08-20T00:59:59+00:00" }, { "name": "yoast/phpunit-polyfills", @@ -3884,10 +3830,10 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], + "platform": {}, + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/includes/class-pattern-builder-cloud-controller.php b/includes/class-pattern-builder-cloud-controller.php new file mode 100644 index 0000000..29876d2 --- /dev/null +++ b/includes/class-pattern-builder-cloud-controller.php @@ -0,0 +1,332 @@ + rest_authorization_required_code() ) ); + }; + + $routes = array( + '/cloud/status' => array( 'GET', 'status' ), + '/cloud/connect' => array( 'POST', 'connect' ), + '/cloud/connect/complete' => array( 'POST', 'connect_complete' ), + '/cloud/disconnect' => array( 'POST', 'disconnect' ), + '/cloud/library' => array( 'GET', 'library' ), + '/cloud/categories' => array( 'GET', 'categories' ), + '/cloud/directory' => array( 'GET', 'directory' ), + '/cloud/collections' => array( 'GET', 'collections' ), + '/cloud/links' => array( 'GET', 'links' ), + '/cloud/upload' => array( 'POST', 'upload' ), + '/cloud/download' => array( 'POST', 'download' ), + ); + + foreach ( $routes as $route => $handler ) { + register_rest_route( + self::NS, + $route, + array( + 'methods' => $handler[0], + 'permission_callback' => $can_manage, + 'callback' => array( $this, $handler[1] ), + ) + ); + } + + register_rest_route( + self::NS, + '/cloud/library/(?P\d+)', + array( + 'methods' => 'DELETE', + 'permission_callback' => $can_manage, + 'callback' => array( $this, 'delete_library_pattern' ), + ) + ); + } + + /** + * GET /cloud/status — connection state plus live account/usage. + * + * @return WP_REST_Response + */ + public function status() { + if ( ! Pattern_Builder_Cloud::is_connected() ) { + return rest_ensure_response( + array( + 'connected' => false, + 'serviceUrl' => Pattern_Builder_Cloud::service_url(), + ) + ); + } + + $me = Pattern_Builder_Cloud::request( 'GET', '/me' ); + if ( is_wp_error( $me ) ) { + // A dead token was already forgotten by the client; report state. + return rest_ensure_response( + array( + 'connected' => Pattern_Builder_Cloud::is_connected(), + 'serviceUrl' => Pattern_Builder_Cloud::service_url(), + 'error' => $me->get_error_message(), + ) + ); + } + + return rest_ensure_response( + array( + 'connected' => true, + 'serviceUrl' => Pattern_Builder_Cloud::service_url(), + 'account' => $me['account'], + 'tier' => $me['tier'], + 'usage' => $me['usage'], + ) + ); + } + + /** + * POST /cloud/connect — begin the PKCE flow. + * + * @return WP_REST_Response + */ + public function connect() { + return rest_ensure_response( Pattern_Builder_Cloud::start_connect() ); + } + + /** + * POST /cloud/connect/complete — exchange the returned code. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function connect_complete( $request ) { + $result = Pattern_Builder_Cloud::complete_connect( + (string) $request->get_param( 'code' ), + (string) $request->get_param( 'state' ) + ); + if ( is_wp_error( $result ) ) { + return $result; + } + return $this->status(); + } + + /** + * POST /cloud/disconnect + * + * @return WP_REST_Response + */ + public function disconnect() { + Pattern_Builder_Cloud::disconnect(); + return rest_ensure_response( array( 'connected' => false ) ); + } + + /** + * GET /cloud/library — the account's cloud patterns. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function library( $request ) { + return $this->proxy_list( '/library/patterns', $request ); + } + + /** + * GET /cloud/categories — the account's cloud categories. + * + * @return WP_REST_Response|WP_Error + */ + public function categories() { + return rest_ensure_response( Pattern_Builder_Cloud::request( 'GET', '/library/categories' ) ); + } + + /** + * GET /cloud/directory — the public directory. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function directory( $request ) { + return $this->proxy_list( '/directory/patterns', $request ); + } + + /** + * GET /cloud/collections — public + premium collections. + * + * @return WP_REST_Response|WP_Error + */ + public function collections() { + return rest_ensure_response( Pattern_Builder_Cloud::request( 'GET', '/directory/collections' ) ); + } + + /** + * GET /cloud/links — which local patterns have cloud copies. + * + * @return WP_REST_Response + */ + public function links() { + return rest_ensure_response( Pattern_Builder_Cloud::links() ); + } + + /** + * POST /cloud/upload — send a local pattern to the account's library. + * + * Params: patternType (theme|user), patternId, categories (string[]), + * asNew (bool — force a new cloud copy even when linked). + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function upload( $request ) { + if ( ! Pattern_Builder_Cloud::is_connected() ) { + return new WP_Error( 'pb_cloud_disconnected', __( 'Connect to patternbuilderwp.com first.', 'pattern-builder' ), array( 'status' => 400 ) ); + } + + $type = 'user' === $request->get_param( 'patternType' ) ? 'user' : 'theme'; + $id = 'user' === $type ? (int) $request->get_param( 'patternId' ) : (string) $request->get_param( 'patternId' ); + + $porter = new Pattern_Builder_Cloud_Porter(); + $exported = $porter->export_local( $type, $id ); + if ( is_wp_error( $exported ) ) { + return $exported; + } + + $categories = $request->get_param( 'categories' ); + if ( is_array( $categories ) && ! empty( $categories ) ) { + $exported['pbp']['categories'] = array_map( 'sanitize_text_field', $categories ); + } + + $links = Pattern_Builder_Cloud::links(); + $existing = isset( $links[ $exported['localKey'] ] ) ? (int) $links[ $exported['localKey'] ]['cloudId'] : 0; + $as_new = (bool) $request->get_param( 'asNew' ); + + if ( $existing && ! $as_new ) { + $result = Pattern_Builder_Cloud::upload( 'PUT', '/library/patterns/' . $existing, $exported['pbp'], $exported['files'] ); + // The cloud copy may have been deleted remotely; fall through to create. + if ( is_wp_error( $result ) && 404 === (int) ( $result->get_error_data()['status'] ?? 0 ) ) { + $existing = 0; + } elseif ( is_wp_error( $result ) ) { + return $result; + } + } + + if ( ! $existing || $as_new ) { + $result = Pattern_Builder_Cloud::upload( 'POST', '/library/patterns', $exported['pbp'], $exported['files'] ); + if ( is_wp_error( $result ) ) { + return $result; + } + } + + if ( ! empty( $result['id'] ) ) { + Pattern_Builder_Cloud::set_link( $exported['localKey'], (int) $result['id'] ); + } + + return rest_ensure_response( + array( + 'pattern' => $result, + 'updated' => (bool) $existing && ! $as_new, + 'localKey' => $exported['localKey'], + ) + ); + } + + /** + * POST /cloud/download — bring a cloud pattern onto this site. + * + * Params: source (library|directory), cloudId, destination (user|theme). + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function download( $request ) { + $source = 'library' === $request->get_param( 'source' ) ? 'library' : 'directory'; + $cloud_id = (int) $request->get_param( 'cloudId' ); + $destination = 'theme' === $request->get_param( 'destination' ) ? 'theme' : 'user'; + + if ( ! $cloud_id ) { + return new WP_Error( 'pb_cloud_bad_request', __( 'Which pattern?', 'pattern-builder' ), array( 'status' => 400 ) ); + } + if ( 'library' === $source && ! Pattern_Builder_Cloud::is_connected() ) { + return new WP_Error( 'pb_cloud_disconnected', __( 'Connect to patternbuilderwp.com first.', 'pattern-builder' ), array( 'status' => 400 ) ); + } + + $pbp = Pattern_Builder_Cloud::request( 'POST', "/{$source}/patterns/{$cloud_id}/download" ); + if ( is_wp_error( $pbp ) ) { + return $pbp; + } + + $porter = new Pattern_Builder_Cloud_Porter(); + $result = $porter->import_pbp( $pbp, $destination ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Remember the linkage so re-uploads offer "update". + Pattern_Builder_Cloud::set_link( Pattern_Builder_Cloud_Porter::local_key( $result['type'], $result['id'] ), $cloud_id ); + + return rest_ensure_response( $result ); + } + + /** + * DELETE /cloud/library/{id} — remove a cloud pattern (frees a slot). + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function delete_library_pattern( $request ) { + $result = Pattern_Builder_Cloud::request( 'DELETE', '/library/patterns/' . (int) $request['id'] ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Forget any local links pointing at it. + foreach ( Pattern_Builder_Cloud::links() as $key => $link ) { + if ( (int) $link['cloudId'] === (int) $request['id'] ) { + Pattern_Builder_Cloud::set_link( $key, null ); + } + } + + return rest_ensure_response( $result ); + } + + /** + * Proxy a paged listing endpoint. + * + * @param string $path Service path. + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + private function proxy_list( $path, $request ) { + $query = array(); + foreach ( array( 'page', 'per_page', 'search', 'category' ) as $param ) { + $value = $request->get_param( $param ); + if ( null !== $value && '' !== $value ) { + $query[ $param ] = sanitize_text_field( (string) $value ); + } + } + + return rest_ensure_response( Pattern_Builder_Cloud::request( 'GET', $path, array( 'query' => $query ) ) ); + } +} diff --git a/includes/class-pattern-builder-cloud-porter.php b/includes/class-pattern-builder-cloud-porter.php new file mode 100644 index 0000000..980f569 --- /dev/null +++ b/includes/class-pattern-builder-cloud-porter.php @@ -0,0 +1,417 @@ +store = new Pattern_File_Store(); + } + + /** + * Export a local pattern to PBP form. + * + * @param string $type 'theme' or 'user'. + * @param string|int $id Theme pattern name or wp_block post ID. + * @return array|WP_Error { pbp: array, files: array (key => path), localKey: string } + */ + public function export_local( $type, $id ) { + $pattern = $this->load_local( $type, $id ); + if ( is_wp_error( $pattern ) ) { + return $pattern; + } + + $content = (string) $pattern->content; + $files = array(); + $assets = array(); + + $collected = $this->collect_local_assets( $content ); + if ( is_wp_error( $collected ) ) { + return $collected; + } + + foreach ( $collected as $url => $info ) { + $content = str_replace( $url, 'pbp-asset://' . $info['key'], $content ); + + $files[ $info['key'] ] = $info['path']; + $assets[ $info['key'] ] = array( + 'key' => $info['key'], + 'mime' => $info['mime'], + 'filename' => basename( $info['path'] ), + ); + } + + $slug = $pattern->name ? basename( (string) $pattern->name ) : sanitize_title( $pattern->title ); + + $pbp = array( + 'format' => 'pbp/1', + 'title' => $pattern->title, + 'slug' => $slug, + 'description' => (string) $pattern->description, + 'keywords' => array_values( (array) $pattern->keywords ), + 'categories' => array_values( (array) $pattern->categories ), + 'viewportWidth' => (int) $pattern->viewportWidth, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'synced' => (bool) $pattern->synced, + 'blockTypes' => array_values( (array) $pattern->blockTypes ), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'postTypes' => array_values( (array) $pattern->postTypes ), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'templateTypes' => array_values( (array) $pattern->templateTypes ), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'content' => $content, + 'assets' => array_values( $assets ), + 'origin' => array( + 'site' => home_url(), + 'kind' => $type, + ), + ); + + return array( + 'pbp' => $pbp, + 'files' => $files, + 'localKey' => self::local_key( $type, $id ), + ); + } + + /** + * Import a PBP as a local pattern. + * + * @param array $pbp Package from the service. + * @param string $destination 'user' or 'theme'. + * @return array|WP_Error { type: string, id: string|int, title: string } + */ + public function import_pbp( $pbp, $destination ) { + if ( ! is_array( $pbp ) || empty( $pbp['content'] ) || empty( $pbp['title'] ) ) { + return new WP_Error( 'pb_cloud_bad_package', __( 'The downloaded pattern package is malformed.', 'pattern-builder' ), array( 'status' => 502 ) ); + } + + $content = (string) $pbp['content']; + + // 1. Fetch the package's assets into the media library. + if ( ! empty( $pbp['assets'] ) && is_array( $pbp['assets'] ) ) { + foreach ( $pbp['assets'] as $asset ) { + if ( empty( $asset['key'] ) || empty( $asset['url'] ) ) { + continue; + } + $local_url = $this->import_asset( $asset ); + if ( is_wp_error( $local_url ) ) { + return $local_url; + } + $content = str_replace( + array( 'pbp-asset://' . $asset['key'], 'pbp-asset:\/\/' . $asset['key'] ), + array( $local_url, str_replace( '/', '\/', $local_url ) ), + $content + ); + } + } + + if ( false !== strpos( $content, 'pbp-asset:' ) ) { + return new WP_Error( 'pb_cloud_missing_asset', __( 'The downloaded pattern references an asset that was not delivered.', 'pattern-builder' ), array( 'status' => 502 ) ); + } + + // 2. Re-sanitize: never trust the wire, even our own service. + $content = $this->sanitize_content( $content ); + if ( is_wp_error( $content ) ) { + return $content; + } + + $title = sanitize_text_field( (string) $pbp['title'] ); + $slug = sanitize_title( ! empty( $pbp['slug'] ) ? (string) $pbp['slug'] : $title ); + $description = sanitize_textarea_field( isset( $pbp['description'] ) ? (string) $pbp['description'] : '' ); + $categories = array_map( 'sanitize_text_field', isset( $pbp['categories'] ) && is_array( $pbp['categories'] ) ? $pbp['categories'] : array() ); + $synced = ! empty( $pbp['synced'] ); + + // 3. Land it as the requested local kind. + if ( 'theme' === $destination ) { + return $this->import_as_theme_pattern( $pbp, $title, $slug, $description, $categories, $synced, $content ); + } + + return $this->import_as_user_pattern( $title, $slug, $description, $categories, $synced, $content ); + } + + /** + * Build the link-map key for a local pattern. + * + * @param string $type 'theme' or 'user'. + * @param string|int $id Local identifier. + * @return string + */ + public static function local_key( $type, $id ) { + return $type . ':' . $id; + } + + /** + * Load a local pattern as an Abstract_Pattern. + * + * @param string $type 'theme' or 'user'. + * @param string|int $id Local identifier. + * @return Abstract_Pattern|WP_Error + */ + private function load_local( $type, $id ) { + if ( 'theme' === $type ) { + $pattern = $this->store->find_theme_pattern( (string) $id ); + if ( ! $pattern ) { + return new WP_Error( 'pb_cloud_not_found', __( 'Theme pattern not found.', 'pattern-builder' ), array( 'status' => 404 ) ); + } + return $pattern; + } + + $post = get_post( (int) $id ); + if ( ! $post || 'wp_block' !== $post->post_type ) { + return new WP_Error( 'pb_cloud_not_found', __( 'User pattern not found.', 'pattern-builder' ), array( 'status' => 404 ) ); + } + return Abstract_Pattern::from_post( $post ); + } + + /** + * Find every local image URL in content and resolve it to a file on disk. + * + * @param string $content Block markup. + * @return array|WP_Error url => { key, path, mime } + */ + private function collect_local_assets( $content ) { + $home = untrailingslashit( home_url() ); + + preg_match_all( '/(?:src|href)="(' . preg_quote( $home, '/' ) . '[^"]+\.(?:jpg|jpeg|png|gif|webp))"/i', $content, $attr_matches ); + preg_match_all( '/"url"\s*:\s*"(' . preg_quote( $home, '/' ) . '[^"]+\.(?:jpg|jpeg|png|gif|webp))"/i', $content, $json_matches ); + + $urls = array_unique( array_merge( $attr_matches[1], $json_matches[1] ) ); + $assets = array(); + + foreach ( $urls as $url ) { + $path = $this->url_to_path( $url ); + if ( is_wp_error( $path ) ) { + return $path; + } + + $mime = wp_check_filetype( basename( $path ) )['type']; + $name = sanitize_key( preg_replace( '/\.[^.]+$/', '', basename( $path ) ) ); + $name = preg_replace( '/[^a-z0-9_\-]/', '', str_replace( '.', '-', $name ) ); + $key = substr( $name, 0, 60 ) . '-' . substr( md5( $url ), 0, 6 ); + + $assets[ $url ] = array( + 'key' => $key, + 'path' => $path, + 'mime' => $mime ? $mime : 'image/png', + ); + } + + return $assets; + } + + /** + * Map a local URL to its file on disk (uploads or theme directories). + * + * @param string $url Local URL. + * @return string|WP_Error + */ + private function url_to_path( $url ) { + $candidates = array(); + + $uploads = wp_get_upload_dir(); + if ( 0 === strpos( $url, $uploads['baseurl'] ) ) { + $candidates[] = str_replace( $uploads['baseurl'], $uploads['basedir'], $url ); + } + if ( 0 === strpos( $url, get_stylesheet_directory_uri() ) ) { + $candidates[] = str_replace( get_stylesheet_directory_uri(), get_stylesheet_directory(), $url ); + } + if ( 0 === strpos( $url, get_template_directory_uri() ) ) { + $candidates[] = str_replace( get_template_directory_uri(), get_template_directory(), $url ); + } + + foreach ( $candidates as $candidate ) { + $candidate = strtok( $candidate, '?' ); + if ( file_exists( $candidate ) ) { + return $candidate; + } + } + + return new WP_Error( + 'pb_cloud_unresolvable_asset', + sprintf( + /* translators: %s: image URL. */ + __( 'The pattern references an image that could not be found on this site: %s', 'pattern-builder' ), + $url + ), + array( 'status' => 400 ) + ); + } + + /** + * Fetch one package asset from the service into the media library. + * + * @param array $asset Asset entry (key, url, filename, mime). + * @return string|WP_Error The local attachment URL. + */ + private function import_asset( $asset ) { + $url = (string) $asset['url']; + + /** + * Pre-empt the remote fetch (tests, mirrors). Return a readable file + * path to use instead of downloading. + * + * @param string|null $path Local file path or null. + * @param array $asset Asset entry. + */ + $pre = apply_filters( 'pattern_builder_cloud_pre_fetch_asset', null, $asset ); + + if ( null === $pre ) { + $service_host = wp_parse_url( Pattern_Builder_Cloud::service_url(), PHP_URL_HOST ); + $asset_host = wp_parse_url( $url, PHP_URL_HOST ); + if ( ! $asset_host || strtolower( $asset_host ) !== strtolower( (string) $service_host ) ) { + return new WP_Error( 'pb_cloud_foreign_asset', __( 'The pattern package referenced an asset outside the pattern service.', 'pattern-builder' ), array( 'status' => 502 ) ); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + $temp = download_url( $url, 60 ); + if ( is_wp_error( $temp ) ) { + return new WP_Error( 'pb_cloud_asset_failed', __( 'Could not download a pattern image from the service.', 'pattern-builder' ), array( 'status' => 502 ) ); + } + } else { + $temp_copy = wp_tempnam( basename( $pre ) ); + copy( $pre, $temp_copy ); + $temp = $temp_copy; + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $filename = ! empty( $asset['filename'] ) ? sanitize_file_name( $asset['filename'] ) : sanitize_file_name( $asset['key'] . '.png' ); + + $attachment_id = media_handle_sideload( + array( + 'name' => $filename, + 'tmp_name' => $temp, + 'error' => 0, + 'size' => filesize( $temp ), + ) + ); + + if ( is_wp_error( $attachment_id ) ) { + return $attachment_id; + } + + return wp_get_attachment_url( $attachment_id ); + } + + /** + * Defense-in-depth sanitization of downloaded markup. + * + * @param string $content Markup (local URLs already in place). + * @return string|WP_Error + */ + private function sanitize_content( $content ) { + $decoded = html_entity_decode( $content, ENT_QUOTES ); + $folded = strtolower( preg_replace( '/[\s\x00-\x1f]+/', '', $decoded ) ); + if ( false !== strpos( $folded, 'javascript:' ) || false !== strpos( $folded, 'vbscript:' ) ) { + return new WP_Error( 'pb_cloud_unsafe_content', __( 'The downloaded pattern contained unsafe content and was rejected.', 'pattern-builder' ), array( 'status' => 502 ) ); + } + + return wp_kses_post( $content ); + } + + /** + * Land a package as a wp_block user pattern. + * + * @param string $title Title. + * @param string $slug Slug. + * @param string $description Description. + * @param string[] $categories Category names. + * @param bool $synced Synced flag. + * @param string $content Sanitized markup with local URLs. + * @return array|WP_Error + */ + private function import_as_user_pattern( $title, $slug, $description, $categories, $synced, $content ) { + $post_id = wp_insert_post( + array( + 'post_title' => $title, + 'post_name' => $slug, + 'post_content' => wp_slash( $content ), + 'post_excerpt' => $description, + 'post_type' => 'wp_block', + 'post_status' => 'publish', + ), + true + ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + if ( $synced ) { + delete_post_meta( $post_id, 'wp_pattern_sync_status' ); + } else { + update_post_meta( $post_id, 'wp_pattern_sync_status', 'unsynced' ); + } + + if ( ! empty( $categories ) ) { + wp_set_object_terms( $post_id, $categories, 'wp_pattern_category', false ); + } + + return array( + 'type' => 'user', + 'id' => $post_id, + 'title' => $title, + ); + } + + /** + * Land a package as a theme pattern file. + * + * @param array $pbp Package (for viewport/keywords extras). + * @param string $title Title. + * @param string $slug Slug. + * @param string $description Description. + * @param string[] $categories Category names. + * @param bool $synced Synced flag. + * @param string $content Sanitized markup with local URLs. + * @return array|WP_Error + */ + private function import_as_theme_pattern( $pbp, $title, $slug, $description, $categories, $synced, $content ) { + $pattern = new Abstract_Pattern( + array( + 'id' => get_stylesheet() . '/' . $slug, + 'name' => get_stylesheet() . '/' . $slug, + 'title' => $title, + 'description' => $description, + 'content' => $content, + 'categories' => $categories, + 'keywords' => isset( $pbp['keywords'] ) && is_array( $pbp['keywords'] ) ? array_map( 'sanitize_text_field', $pbp['keywords'] ) : array(), + 'viewportWidth' => isset( $pbp['viewportWidth'] ) ? absint( $pbp['viewportWidth'] ) : null, + 'synced' => $synced, + 'inserter' => true, + 'source' => 'theme', + ) + ); + + $saved = $this->store->update_theme_pattern( $pattern ); + if ( is_wp_error( $saved ) ) { + return $saved; + } + + return array( + 'type' => 'theme', + 'id' => $saved->name, + 'title' => $saved->title, + ); + } +} diff --git a/includes/class-pattern-builder-cloud.php b/includes/class-pattern-builder-cloud.php new file mode 100644 index 0000000..668e92b --- /dev/null +++ b/includes/class-pattern-builder-cloud.php @@ -0,0 +1,348 @@ + $verifier, + 'state' => $state, + 'created' => time(), + ) + ); + + $challenge = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + $authorize_url = add_query_arg( + array( + 'pbwp_authorize' => 1, + 'client' => rawurlencode( home_url() ), + 'site_user' => rawurlencode( wp_get_current_user()->user_login ), + 'redirect_uri' => rawurlencode( self::callback_url() ), + 'state' => $state, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ), + self::service_url() . '/' + ); + + return array( 'authorizeUrl' => $authorize_url ); + } + + /** + * The wp-admin URL the service redirects back to with the code. + * + * @return string + */ + public static function callback_url() { + return admin_url( 'admin.php?page=pattern-builder&pbcloud-callback=1' ); + } + + /** + * Complete the connect flow: verify state, exchange the code, store the token. + * + * @param string $code Authorization code from the service. + * @param string $state State returned by the service. + * @return array|WP_Error Account info. + */ + public static function complete_connect( $code, $state ) { + $pkce = get_user_meta( get_current_user_id(), self::META_PKCE, true ); + delete_user_meta( get_current_user_id(), self::META_PKCE ); + + if ( ! is_array( $pkce ) || empty( $pkce['verifier'] ) || empty( $pkce['state'] ) ) { + return new WP_Error( 'pb_cloud_no_pkce', __( 'The connection attempt expired. Start the connection again.', 'pattern-builder' ), array( 'status' => 400 ) ); + } + if ( ( time() - (int) $pkce['created'] ) > self::PKCE_TTL || ! hash_equals( $pkce['state'], (string) $state ) ) { + return new WP_Error( 'pb_cloud_bad_state', __( 'The connection attempt could not be verified. Start the connection again.', 'pattern-builder' ), array( 'status' => 400 ) ); + } + + $response = wp_remote_post( + self::endpoint( '/connect/token' ), + array( + 'timeout' => 30, + 'body' => array( + 'code' => $code, + 'code_verifier' => $pkce['verifier'], + ), + ) + ); + + $data = self::parse_response( $response ); + if ( is_wp_error( $data ) ) { + return $data; + } + if ( empty( $data['token'] ) ) { + return new WP_Error( 'pb_cloud_no_token', __( 'The service did not return a connection token.', 'pattern-builder' ), array( 'status' => 502 ) ); + } + + update_user_meta( get_current_user_id(), self::META_TOKEN, $data['token'] ); + update_user_meta( get_current_user_id(), self::META_ACCOUNT, isset( $data['account'] ) ? (array) $data['account'] : array() ); + + return self::account(); + } + + /** + * Disconnect: revoke remotely (best effort) and forget the token. + */ + public static function disconnect() { + if ( self::is_connected() ) { + self::request( 'POST', '/connect/revoke' ); + } + delete_user_meta( get_current_user_id(), self::META_TOKEN ); + delete_user_meta( get_current_user_id(), self::META_ACCOUNT ); + } + + /** + * Authenticated request to the service. + * + * @param string $method HTTP method. + * @param string $path Route path within pbwp/v1. + * @param array $args { query?: array, body?: array (JSON) } + * @return array|WP_Error Decoded response. + */ + public static function request( $method, $path, $args = array() ) { + $url = self::endpoint( $path ); + if ( ! empty( $args['query'] ) ) { + $url .= '&' . http_build_query( $args['query'] ); + } + + $request = array( + 'method' => $method, + 'timeout' => 30, + 'headers' => self::auth_headers(), + ); + + if ( isset( $args['body'] ) ) { + $request['headers']['Content-Type'] = 'application/json'; + $request['body'] = wp_json_encode( $args['body'] ); + } + + $response = wp_remote_request( $url, $request ); + return self::parse_response( $response ); + } + + /** + * Multipart upload request (the PBP JSON plus asset files). + * + * @param string $method HTTP method (POST/PUT). + * @param string $path Route path within pbwp/v1. + * @param array $pbp Package array. + * @param array $files key => absolute file path. + * @return array|WP_Error Decoded response. + */ + public static function upload( $method, $path, $pbp, $files ) { + $boundary = 'pbcloud' . bin2hex( random_bytes( 12 ) ); + $body = ''; + + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"pbp\"\r\n\r\n"; + $body .= wp_json_encode( $pbp ) . "\r\n"; + + foreach ( $files as $key => $file_path ) { + $contents = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Local file. + if ( false === $contents ) { + continue; + } + $filename = basename( $file_path ); + $mime = wp_check_filetype( $filename )['type']; + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"asset_{$key}\"; filename=\"{$filename}\"\r\n"; + $body .= 'Content-Type: ' . ( $mime ? $mime : 'application/octet-stream' ) . "\r\n\r\n"; + $body .= $contents . "\r\n"; + } + + $body .= "--{$boundary}--\r\n"; + + $headers = self::auth_headers(); + $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; + + $response = wp_remote_request( + self::endpoint( $path ), + array( + 'method' => $method, + 'timeout' => 60, + 'headers' => $headers, + 'body' => $body, + ) + ); + + return self::parse_response( $response ); + } + + /** + * The cloud-link map: which local patterns are linked to cloud copies. + * + * @return array localKey => { cloudId: int, account: int } + */ + public static function links() { + $links = get_option( self::OPTION_LINKS ); + return is_array( $links ) ? $links : array(); + } + + /** + * Remember (or forget) a local pattern's cloud copy. + * + * @param string $local_key "theme:{name}" or "user:{postId}". + * @param int|null $cloud_id Cloud pattern ID, or null to forget. + */ + public static function set_link( $local_key, $cloud_id ) { + $links = self::links(); + if ( null === $cloud_id ) { + unset( $links[ $local_key ] ); + } else { + $links[ $local_key ] = array( + 'cloudId' => (int) $cloud_id, + 'account' => (int) ( self::account()['id'] ?? 0 ), + ); + } + update_option( self::OPTION_LINKS, $links, false ); + } + + /** + * Bearer headers (plus the fallback header some hosts require). + * + * @return array + */ + private static function auth_headers() { + $token = self::token(); + if ( ! $token ) { + return array(); + } + return array( + 'Authorization' => 'Bearer ' . $token, + 'X-Pbwp-Authorization' => 'Bearer ' . $token, + ); + } + + /** + * Decode a service response; surface service errors as WP_Error. + * + * @param array|WP_Error $response HTTP API response. + * @return array|WP_Error + */ + private static function parse_response( $response ) { + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'pb_cloud_unreachable', + sprintf( + /* translators: %s: connection error detail. */ + __( 'Could not reach patternbuilderwp.com: %s', 'pattern-builder' ), + $response->get_error_message() + ), + array( 'status' => 502 ) + ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $code >= 400 ) { + $message = is_array( $data ) && ! empty( $data['message'] ) + ? $data['message'] + : __( 'The pattern service returned an error.', 'pattern-builder' ); + $error_code = is_array( $data ) && ! empty( $data['code'] ) ? $data['code'] : 'pb_cloud_error'; + + // An invalid token means the connection is gone; forget it. + if ( 401 === $code && self::is_connected() ) { + delete_user_meta( get_current_user_id(), self::META_TOKEN ); + delete_user_meta( get_current_user_id(), self::META_ACCOUNT ); + } + + return new WP_Error( $error_code, $message, array( 'status' => $code ) ); + } + + return is_array( $data ) ? $data : array(); + } +} diff --git a/includes/class-pattern-builder.php b/includes/class-pattern-builder.php index c50838a..2fc03f8 100644 --- a/includes/class-pattern-builder.php +++ b/includes/class-pattern-builder.php @@ -18,6 +18,9 @@ require_once __DIR__ . '/class-pattern-builder-admin.php'; require_once __DIR__ . '/class-pattern-builder-editor.php'; require_once __DIR__ . '/class-pattern-builder-migration.php'; +require_once __DIR__ . '/class-pattern-builder-cloud.php'; +require_once __DIR__ . '/class-pattern-builder-cloud-porter.php'; +require_once __DIR__ . '/class-pattern-builder-cloud-controller.php'; /** * Main class for managing the Pattern Builder plugin. @@ -52,6 +55,7 @@ private function __construct() { new Pattern_Builder_Admin(); new Pattern_Builder_Editor(); new Pattern_Builder_Migration(); + new Pattern_Builder_Cloud_Controller(); } /** diff --git a/package-lock.json b/package-lock.json index 4701592..8481661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pattern-builder", - "version": "1.0.4", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pattern-builder", - "version": "1.0.4", + "version": "2.0.0", "devDependencies": { "@jest/globals": "^29.7.0", "@wordpress/block-editor": "^14.18.0", diff --git a/src/admin/PatternBrowser.js b/src/admin/PatternBrowser.js index 11aa03a..e2a9bb6 100644 --- a/src/admin/PatternBrowser.js +++ b/src/admin/PatternBrowser.js @@ -21,6 +21,12 @@ import { fetchAllPatterns } from '../utils/resolvers'; import { PatternCard } from '../components/PatternCard'; import { PatternDetailsPanel } from '../components/PatternDetailsPanel'; import { PatternCreatePanel } from '../components/PatternCreatePanel'; +import { + CloudBrowser, + CLOUD_LIBRARY, + CLOUD_DIRECTORY, + readConnectCallback, +} from '../cloud/CloudBrowser'; const ALL = 'all'; const MINE = 'mine'; @@ -76,7 +82,11 @@ function CategoryRail( { categories, active, onSelect } ) { export function PatternBrowser( { onEdit, editorSettings } ) { const [ patterns, setPatterns ] = useState( null ); const [ search, setSearch ] = useState( '' ); - const [ category, setCategory ] = useState( ALL ); + // Landing back from the patternbuilderwp.com connect flow opens the + // cloud library, where CloudBrowser completes the code exchange. + const [ category, setCategory ] = useState( + readConnectCallback() ? CLOUD_LIBRARY : ALL + ); const [ selectedId, setSelectedId ] = useState( null ); const [ isCreateOpen, setIsCreateOpen ] = useState( false ); @@ -152,6 +162,19 @@ export function PatternBrowser( { onEdit, editorSettings } ) { } ); } + rail.push( + { + slug: CLOUD_LIBRARY, + label: __( 'My Cloud Library', 'pattern-builder' ), + count: '', + }, + { + slug: CLOUD_DIRECTORY, + label: __( 'Pattern Directory', 'pattern-builder' ), + count: '', + } + ); + return rail; }, [ patterns, registeredCategories ] ); @@ -247,61 +270,87 @@ export function PatternBrowser( { onEdit, editorSettings } ) { /> -
- - + - - - - { filteredPatterns.length === 0 && ( -

{ __( 'No patterns found.', 'pattern-builder' ) }

+
+ ) } + + { category !== CLOUD_LIBRARY && + category !== CLOUD_DIRECTORY && ( +
+ + + + + + { filteredPatterns.length === 0 && ( +

+ { __( + 'No patterns found.', + 'pattern-builder' + ) } +

+ ) } + +
+ { filteredPatterns.map( ( pattern ) => ( + + setSelectedId( + selected.id === selectedId + ? null + : selected.id + ) + } + /> + ) ) } +
+
) } -
- { filteredPatterns.map( ( pattern ) => ( - - setSelectedId( - selected.id === selectedId - ? null - : selected.id - ) - } + { category !== CLOUD_LIBRARY && + category !== CLOUD_DIRECTORY && + selectedPattern && ( +
- - - { selectedPattern && ( - - ) } + + ) } { isCreateOpen && ( + url.searchParams.delete( key ) + ); + window.history.replaceState( {}, '', url.toString() ); +} + +/** + * A cloud pattern card: live iframe preview, title, premium badge. + * + * @param {Object} props Component props. + * @param {Object} props.pattern Cloud pattern summary. + * @param {boolean} props.isSelected Whether the card is selected. + * @param {Function} props.onSelect Selection callback. + */ +function CloudCard( { pattern, isSelected, onSelect } ) { + return ( +