From ce85e197fa577b2097c8062fcce0d8dd8969c103 Mon Sep 17 00:00:00 2001 From: Alex Kazhukhouski Date: Mon, 10 Mar 2025 16:22:24 +0100 Subject: [PATCH 1/3] POC Previews Plugin --- plugins/hwp-previews/README.md | 100 +++ plugins/hwp-previews/composer.json | 16 + plugins/hwp-previews/composer.lock | 84 ++ plugins/hwp-previews/hwp-previews.php | 38 + plugins/hwp-previews/src/Plugin.php | 289 +++++++ .../src/Post/Data/Post_Data_Model.php | 29 + .../Post_Parent_Manager_Interface.php | 13 + .../src/Post/Parent/Post_Parent_Manager.php | 35 + .../Contracts/Post_Slug_Manager_Interface.php | 15 + .../Post_Slug_Repository_Interface.php | 11 + .../src/Post/Slug/Post_Slug_Manager.php | 72 ++ .../src/Post/Slug/Post_Slug_Repository.php | 23 + .../Post_Statuses_Config_Interface.php | 13 + .../src/Post/Status/Post_Statuses_Config.php | 34 + .../Contracts/Post_Types_Config_Interface.php | 19 + .../src/Post/Type/Post_Types_Config.php | 56 ++ .../Preview_Parameter_Names_Model.php | 47 ++ .../Preview_Query_Argument_Interface.php | 12 + .../Preview_Template_Resolver_Interface.php | 13 + .../Template/Preview_Template_Resolver.php | 43 + .../Preview_Parameter_Builder_Interface.php | 13 + .../Preview_URL_Generator_Interface.php | 13 + .../Preview/URL/Preview_Parameter_Builder.php | 72 ++ .../src/Preview/URL/Preview_URL_Generator.php | 48 ++ .../Settings/Headless_Preview_Settings.php | 762 ++++++++++++++++++ plugins/hwp-previews/src/Shared/Model.php | 24 + .../Auth/Contracts/Token_Auth_Interface.php | 11 + .../src/Token/Auth/Token_Auth.php | 29 + .../Contracts/Token_Extractor_Interface.php | 11 + .../src/Token/Extractor/Token_Extractor.php | 23 + .../Contracts/Token_Generator_Interface.php | 11 + .../src/Token/Generator/Token_Generator.php | 24 + .../Contracts/Token_Manager_Interface.php | 29 + .../src/Token/Manager/JWT_Token_Manager.php | 96 +++ .../Token_REST_Controller_Interface.php | 11 + .../src/Token/REST/Token_REST_Controller.php | 68 ++ .../Contracts/Token_Verifier_Interface.php | 13 + .../src/Token/Verifier/Token_Verifier.php | 33 + .../hwp-previews/templates/hwp-preview.php | 41 + plugins/hwp-previews/vendor/autoload.php | 25 + .../vendor/composer/ClassLoader.php | 579 +++++++++++++ .../vendor/composer/InstalledVersions.php | 362 +++++++++ plugins/hwp-previews/vendor/composer/LICENSE | 21 + .../vendor/composer/autoload_classmap.php | 10 + .../vendor/composer/autoload_namespaces.php | 9 + .../vendor/composer/autoload_psr4.php | 11 + .../vendor/composer/autoload_real.php | 38 + .../vendor/composer/autoload_static.php | 44 + .../vendor/composer/installed.json | 72 ++ .../vendor/composer/installed.php | 32 + .../vendor/composer/platform_check.php | 26 + .../vendor/firebase/php-jwt/CHANGELOG.md | 198 +++++ .../vendor/firebase/php-jwt/LICENSE | 30 + .../vendor/firebase/php-jwt/README.md | 425 ++++++++++ .../vendor/firebase/php-jwt/composer.json | 42 + .../php-jwt/src/BeforeValidException.php | 18 + .../firebase/php-jwt/src/CachedKeySet.php | 274 +++++++ .../firebase/php-jwt/src/ExpiredException.php | 18 + .../vendor/firebase/php-jwt/src/JWK.php | 355 ++++++++ .../vendor/firebase/php-jwt/src/JWT.php | 667 +++++++++++++++ .../src/JWTExceptionWithPayloadInterface.php | 20 + .../vendor/firebase/php-jwt/src/Key.php | 55 ++ .../php-jwt/src/SignatureInvalidException.php | 7 + 63 files changed, 5632 insertions(+) create mode 100644 plugins/hwp-previews/README.md create mode 100644 plugins/hwp-previews/composer.json create mode 100644 plugins/hwp-previews/composer.lock create mode 100644 plugins/hwp-previews/hwp-previews.php create mode 100644 plugins/hwp-previews/src/Plugin.php create mode 100644 plugins/hwp-previews/src/Post/Data/Post_Data_Model.php create mode 100644 plugins/hwp-previews/src/Post/Parent/Contracts/Post_Parent_Manager_Interface.php create mode 100644 plugins/hwp-previews/src/Post/Parent/Post_Parent_Manager.php create mode 100644 plugins/hwp-previews/src/Post/Slug/Contracts/Post_Slug_Manager_Interface.php create mode 100644 plugins/hwp-previews/src/Post/Slug/Contracts/Post_Slug_Repository_Interface.php create mode 100644 plugins/hwp-previews/src/Post/Slug/Post_Slug_Manager.php create mode 100644 plugins/hwp-previews/src/Post/Slug/Post_Slug_Repository.php create mode 100644 plugins/hwp-previews/src/Post/Status/Contracts/Post_Statuses_Config_Interface.php create mode 100644 plugins/hwp-previews/src/Post/Status/Post_Statuses_Config.php create mode 100644 plugins/hwp-previews/src/Post/Type/Contracts/Post_Types_Config_Interface.php create mode 100644 plugins/hwp-previews/src/Post/Type/Post_Types_Config.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Names_Model.php create mode 100644 plugins/hwp-previews/src/Preview/Template/Contracts/Preview_Query_Argument_Interface.php create mode 100644 plugins/hwp-previews/src/Preview/Template/Contracts/Preview_Template_Resolver_Interface.php create mode 100644 plugins/hwp-previews/src/Preview/Template/Preview_Template_Resolver.php create mode 100644 plugins/hwp-previews/src/Preview/URL/Contracts/Preview_Parameter_Builder_Interface.php create mode 100644 plugins/hwp-previews/src/Preview/URL/Contracts/Preview_URL_Generator_Interface.php create mode 100644 plugins/hwp-previews/src/Preview/URL/Preview_Parameter_Builder.php create mode 100644 plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php create mode 100644 plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php create mode 100644 plugins/hwp-previews/src/Shared/Model.php create mode 100644 plugins/hwp-previews/src/Token/Auth/Contracts/Token_Auth_Interface.php create mode 100644 plugins/hwp-previews/src/Token/Auth/Token_Auth.php create mode 100644 plugins/hwp-previews/src/Token/Extractor/Contracts/Token_Extractor_Interface.php create mode 100644 plugins/hwp-previews/src/Token/Extractor/Token_Extractor.php create mode 100644 plugins/hwp-previews/src/Token/Generator/Contracts/Token_Generator_Interface.php create mode 100644 plugins/hwp-previews/src/Token/Generator/Token_Generator.php create mode 100644 plugins/hwp-previews/src/Token/Manager/Contracts/Token_Manager_Interface.php create mode 100644 plugins/hwp-previews/src/Token/Manager/JWT_Token_Manager.php create mode 100644 plugins/hwp-previews/src/Token/REST/Contracts/Token_REST_Controller_Interface.php create mode 100644 plugins/hwp-previews/src/Token/REST/Token_REST_Controller.php create mode 100644 plugins/hwp-previews/src/Token/Verifier/Contracts/Token_Verifier_Interface.php create mode 100644 plugins/hwp-previews/src/Token/Verifier/Token_Verifier.php create mode 100644 plugins/hwp-previews/templates/hwp-preview.php create mode 100644 plugins/hwp-previews/vendor/autoload.php create mode 100644 plugins/hwp-previews/vendor/composer/ClassLoader.php create mode 100644 plugins/hwp-previews/vendor/composer/InstalledVersions.php create mode 100644 plugins/hwp-previews/vendor/composer/LICENSE create mode 100644 plugins/hwp-previews/vendor/composer/autoload_classmap.php create mode 100644 plugins/hwp-previews/vendor/composer/autoload_namespaces.php create mode 100644 plugins/hwp-previews/vendor/composer/autoload_psr4.php create mode 100644 plugins/hwp-previews/vendor/composer/autoload_real.php create mode 100644 plugins/hwp-previews/vendor/composer/autoload_static.php create mode 100644 plugins/hwp-previews/vendor/composer/installed.json create mode 100644 plugins/hwp-previews/vendor/composer/installed.php create mode 100644 plugins/hwp-previews/vendor/composer/platform_check.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/CHANGELOG.md create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/LICENSE create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/README.md create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/composer.json create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/BeforeValidException.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/CachedKeySet.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/ExpiredException.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/JWK.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/JWT.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/Key.php create mode 100644 plugins/hwp-previews/vendor/firebase/php-jwt/src/SignatureInvalidException.php diff --git a/plugins/hwp-previews/README.md b/plugins/hwp-previews/README.md new file mode 100644 index 0000000..57eaf86 --- /dev/null +++ b/plugins/hwp-previews/README.md @@ -0,0 +1,100 @@ +# Headless WordPress Preview Plugin + +## Overview + +The Headless WordPress Preview Plugin (HWP Previews) enables seamless preview functionality for headless WordPress implementations. It solves the common challenge of previewing unpublished content when using WordPress as a headless CMS with a separate frontend. +**THE PLUGIN AIMS TO BE VERY MUCH CONFIGURABLE APPROACH FOR THE PREVIEW FUNCTIONALITY, SO IT CAN BE USED WITH ANY FE Framework.** + +## Features + +- **Preview in iFrame**: Display previews within WordPress admin in an iFrame, with JWT token authentication that bypasses typical authentication requirements. This allows you to see your frontend preview directly within WordPress. +- **Filtered Preview URLs**: Alternatively, you can choose to only filter the preview URLs in the default WordPress places and view previews in new tabs without the iFrame approach. +- **Unique Post Slug Management**: Forces unique slugs for specifically configured post types and post statuses, ensuring consistency across all content states (published, draft, pending, etc.). +- **Post Status Parent Support**: Enables configured post statuses of specified post types to be available as parent options in the WordPress admin, allowing for proper hierarchy construction with unpublished content. +- **Draft Route Support**: Provides special handling for Next.js or Nuxt draft mode by allowing configuration of a draft API path that gets concatenated to the preview URL. +- **Token-based Authentication**: Secures preview links with JWT tokens to ensure only authorized users can access unpublished content. +- **REST API Token Verification**: Validates token authenticity for preview REST requests. +- **Configurable Parameters**: Customize URL parameters used for previews. + +## Requirements + +- WordPress 5.7+ +- PHP 7.4+ + +## Configuration + +### Settings + +- **Preview URL**: Set the base URL of your headless frontend +- **Token Secret**: Set a secure string to use for JWT token generation +- **Post Types**: Select which post types should have preview functionality +- **Post Statuses**: Select which post statuses should be previewable +- **Enable Unique Post Slug** (`ENABLE_UNIQUE_POST_SLUG`): Forces unique slug generation for the configured post types and statuses (default: enabled) +- **Enable Post Statuses as Parent** (`ENABLE_POST_STATUSES_AS_PARENT`): Allows selecting unpublished content as parent posts in WordPress admin for the configured post types (default: enabled) +- **Generate Preview Links** (`GENERATE_PREVIEW_LINKS`): Replaces WordPress preview links with headless preview URLs (default: disabled) +- **Token Auth Enabled** (`TOKEN_AUTH_ENABLED`): Secures previews with JWT token-based authentication (default: enabled) +- **Preview in iFrame** (`PREVIEW_IN_IFRAME`): Displays previews in an iFrame within WordPress admin (default: enabled) +- **REST Token Verification** (`REST_TOKEN_VERIFICATION`): Enables token verification for REST API endpoints (default: enabled) +- **Draft Route** (`DRAFT_ROUTE`): Specify a custom route for draft content that gets concatenated to the preview URL. Useful for Next.js or Nuxt draft mode APIs (e.g., `/api/preview` or `/api/draft`) +- **Generate Preview Token** (`GENERATE_PREVIEW_TOKEN`): Controls whether JWT tokens are generated for preview URLs (default: enabled) +- **Preview Parameter Names** (`PREVIEW_PARAMETER_NAMES`): Customize the URL parameter names if needed + +## How It Works + +### iFrame Preview Mode +1. When enabled, this feature intercepts the standard WordPress preview functionality +2. Instead of the default WordPress preview, the plugin renders an iFrame containing your frontend +3. The iFrame URL includes a JWT token that authenticates the user, bypassing normal frontend authentication +4. This allows viewing the frontend preview directly within WordPress admin + +### Alternative: URL Filtering Approach +1. If you prefer not to use the iFrame approach, you can enable just the URL filtering functionality +2. This replaces the standard WordPress preview links with links to your headless frontend +3. Clicking preview will open your frontend in a new tab instead of within an iFrame + +### Unique Slug Management +1. When enabled, the plugin ensures that slugs are unique across all configured post types and statuses +2. This prevents conflicts that can occur when drafts and published content share the same slug +3. Only applies to the post types and statuses you specify in settings + +### Post Status Parent Support +1. By default, WordPress only allows published content to be selected as parent content +2. This feature enables drafts and other unpublished content to appear in parent selection dropdowns +3. Only applies to the post types and statuses configured in settings + +### Draft Route Support +1. For frameworks like Next.js or Nuxt that have special draft mode APIs +2. The plugin can append a configured draft route to the preview URL +3. This activates the draft mode in your frontend framework + +### Token Authentication +1. The plugin generates JWT tokens for secure preview access +2. These tokens can be verified by your frontend to ensure only authorized users can see unpublished content +3. Includes a verification endpoint at `/wp-json/hwp-previews/v1/verify-preview-token?token=` - something that can help FE to validate preview requester + +## Customizing Templates + +You can customize the preview templates by filtering `hwp_previews_template_dir_path`: + +```php +add_filter('hwp_previews_template_dir_path', function($path) { + return get_stylesheet_directory() . '/templates'; +}); +``` + +## Filters + +- `hwp_preview_args`: Allows modifying preview URL parameters +- `hwp_previews_template_dir_path`: Change the template directory for preview templates - completely +- `hwp_previews_header_name` +- `hwp_previews_header_args` + +We can provide more filters if needed. + +## Actions: +- `hwp_previews_before_get_header` +- `hwp_previews_after_get_header` +- `hwp_previews_before_get_footer` +- `hwp_previews_after_get_footer` + +We can provide more actions if needed. diff --git a/plugins/hwp-previews/composer.json b/plugins/hwp-previews/composer.json new file mode 100644 index 0000000..1d40461 --- /dev/null +++ b/plugins/hwp-previews/composer.json @@ -0,0 +1,16 @@ +{ + "name": "wpengine/hwp-previews", + "description": "Plugin for headless post previews.", + "type": "wordpress-plugin", + "prefer-stable": true, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "HWP\\Previews\\": "src/" + } + }, + "require": { + "php": ">=7.4", + "firebase/php-jwt": "^6.11" + } +} \ No newline at end of file diff --git a/plugins/hwp-previews/composer.lock b/plugins/hwp-previews/composer.lock new file mode 100644 index 0000000..de1fc99 --- /dev/null +++ b/plugins/hwp-previews/composer.lock @@ -0,0 +1,84 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "72736c17dd331a5ebe8e0bc4565a896f", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.11.0", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.0" + }, + "time": "2025-01-23T05:11:06+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=7.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/plugins/hwp-previews/hwp-previews.php b/plugins/hwp-previews/hwp-previews.php new file mode 100644 index 0000000..b635d68 --- /dev/null +++ b/plugins/hwp-previews/hwp-previews.php @@ -0,0 +1,38 @@ +init_dependencies(); + $this->register_hooks(); + } + + /** + * Get the singleton instance of the Plugin. + * + * @return Plugin + */ + public static function get_instance(): Plugin { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Initialize all dependencies. + * + * @return void + */ + private function init_dependencies(): void { + $this->settings = new Headless_Preview_Settings(); + + $post_types = $this->settings->get_setting( Headless_Preview_Settings::POST_TYPES, [] ); + $post_statuses = $this->settings->get_setting( Headless_Preview_Settings::POST_STATUSES, [] ); + + if ( ! $post_types || ! $post_statuses ) { + return; // TODO: actually do something here. + } + + // Configs. + $this->types_config = new Post_Types_Config( $post_types ); + $this->statuses_config = new Post_Statuses_Config( $post_statuses ); + + // Dependencies. + $token_secret = $this->settings->get_setting( Headless_Preview_Settings::TOKEN_SECRET, '' ); // is required. + $parameter_names = $this->settings->get_setting( Headless_Preview_Settings::PREVIEW_PARAMETER_NAMES, [ + 'preview' => 'preview', + 'token' => 'token', + 'post_slug' => 'slug', + 'post_id' => 'p', + 'post_type' => 'type', + 'post_uri' => 'uri', + 'graphql_single' => 'gql' + ] ); + + $this->slug_repository = new Post_Slug_Repository(); + $this->template_resolver = new Preview_Template_Resolver( $this->types_config, $this->statuses_config ); + $this->url_generator = new Preview_URL_Generator( $this->types_config, $this->statuses_config ); + $this->parameter_builder = new Preview_Parameter_Builder( + new Preview_Parameter_Names_Model( $parameter_names ) + ); + + // Token related. + $this->jwt_token = new JWT_Token_Manager( $token_secret ); + $this->token_extractor = new Token_Extractor(); + $this->token_generator = new Token_Generator( $this->jwt_token ); + $this->token_verifier = new Token_Verifier( $this->jwt_token ); + $this->token_auth = new Token_Auth( $this->token_verifier ); + $this->token_rest_controller = new Token_Rest_Controller( $this->token_verifier, $this->token_auth::PREVIEW_NONCE_ACTION ); + } + + private function register_hooks(): void { + // Todo: better way of doing settings, these a for the demo only. + + + if ( $this->settings->get_setting( Headless_Preview_Settings::ENABLE_UNIQUE_POST_SLUG, true ) ) { + $this->enable_unique_post_slug(); + } + + if ( $this->settings->get_setting( Headless_Preview_Settings::ENABLE_POST_STATUSES_AS_PARENT, true ) ) { + $this->enable_post_statuses_as_parent(); + } + + if ( $this->settings->get_setting( Headless_Preview_Settings::GENERATE_PREVIEW_LINKS, false ) ) { + $this->enable_generate_preview_url(); + } + + if ( $this->settings->get_setting( Headless_Preview_Settings::TOKEN_AUTH_ENABLED, true ) ) { + $this->enable_user_auth_for_preview(); + } + + if ( $this->settings->get_setting( Headless_Preview_Settings::PREVIEW_IN_IFRAME, true ) ) { + $this->enable_preview_in_iframe_functionality(); + } + + if ( $this->settings->get_setting( Headless_Preview_Settings::REST_TOKEN_VERIFICATION, true ) ) { + $this->enable_rest_route_token_verification(); + } + } + + private function enable_unique_post_slug(): void { + $post_slug_manager = new Post_Slug_Manager( $this->types_config, $this->statuses_config, $this->slug_repository ); + + add_filter( 'wp_insert_post_data', static function ( $data, $postarr ) use ( $post_slug_manager ) { + global $wp_rewrite; + + $post_slug = $post_slug_manager->force_unique_post_slug( + new WP_Post( new Post_Data_Model( $data, (int) ( $postarr['ID'] ?? 0 ) ) ), + $wp_rewrite + ); + + if ( $post_slug ) { + $data['post_name'] = $post_slug; + } + + return $data; + }, 10, 2 ); + } + + private function enable_post_statuses_as_parent(): void { + $post_parent_manager = new Post_Parent_Manager( $this->types_config, $this->statuses_config ); + + $post_parent_manager_callback = static function ( $args ) use ( $post_parent_manager ) { + $post_type = ! empty( $args['post_type'] ) ? get_post_type_object( (string) $args['post_type'] ) : null; + if ( $post_type ) { + $args['post_status'] = $post_parent_manager->get_post_statuses_as_parent( $post_type ); + } + + return $args; + }; + + add_filter( 'page_attributes_dropdown_pages_args', $post_parent_manager_callback ); + add_filter( 'quick_edit_dropdown_pages_args', $post_parent_manager_callback ); + + // And for Gutenberg. + foreach ( $this->types_config->get_post_types() as $post_type ) { + $post_type_object = get_post_type_object( $post_type ); + if ( ! $post_type_object || ! $this->types_config->supports_gutenberg( $post_type_object ) ) { + continue; + } + add_filter( 'rest_' . $post_type . '_query', $post_parent_manager_callback ); + } + } + + private function enable_generate_preview_url(): void { + add_filter( 'preview_post_link', function ( $link, $post ) { + return $this->generate_preview_url( $post ) ?: $link; + }, 10, 2 ); + + /** + * Hack Function that changes the preview link for draft articles, + * this must be removed when wordpress do the properly fix https://github.com/WordPress/gutenberg/issues/13998 + */ + foreach ( $this->types_config->get_post_types() as $post_type ) { + add_filter( 'rest_prepare_' . $post_type, function ( $response, $post ) { + $preview_url = $this->generate_preview_url( $post ); + if ( $preview_url ) { + $response->data['link'] = $preview_url; + } + + return $response; + }, 10, 2 ); + } + } + + private function enable_user_auth_for_preview(): void { + add_filter( 'determine_current_user', function ( $user_id ) { + if ( $user_id ) { + return $user_id; + } + + $token = $this->token_extractor->get_token(); + if ( ! $token ) { + return $user_id; + } + + return $this->token_auth->determine_preview_user( $token ) ?: $user_id; + } ); + } + + private function enable_preview_in_iframe_functionality(): void { + add_filter( 'template_include', function ( $template ) { + if ( ! is_preview() ) { + return $template; + } + + $post = get_post(); + if ( ! $post instanceof WP_Post ) { + return $template; + } + + $template_dir_path = (string) apply_filters( + 'hwp_previews_template_dir_path', + plugin_dir_path( dirname( __FILE__ ) ) . 'templates' + ); + + $preview_template = $this->template_resolver->resolve_template_path( $post, $template_dir_path, true ); + + if ( ! $preview_template ) { // TODO: Do something about it. + return $template; + } + + set_query_var( $this->template_resolver::HWP_PREVIEWS_IFRAME_PREVIEW_URL, $this->generate_preview_url( $post ) ); + + return $preview_template; + }, 999 ); + } + + private function generate_preview_url( WP_Post $post ): string { + $preview_url = $this->settings->get_setting( Headless_Preview_Settings::PREVIEW_URL, '' ); + if ( ! $preview_url ) { + return ''; // todo: maybe do something? + } + + $token_auth = $this->settings->get_setting( Headless_Preview_Settings::TOKEN_AUTH_ENABLED, true ); + $draft_route = $this->settings->get_setting( Headless_Preview_Settings::DRAFT_ROUTE, '' ); + + $token = ''; + if ( $this->settings->get_setting( Headless_Preview_Settings::GENERATE_PREVIEW_TOKEN, true ) ) { + $token = $this->token_generator->generate_token( $token_auth ? [ 'data' => [ 'user' => [ 'id' => get_current_user_id() ] ] ] : [], 'preview_url_nonce', 300 ); + } + + $args = (array) apply_filters( 'hwp_preview_args', $this->parameter_builder->build_preview_args( $post, $token ), $post ); + + return $this->url_generator->generate_url( $post, $preview_url, $args, $draft_route ); + } + + public function enable_rest_route_token_verification(): void { + add_action( 'rest_api_init', function () { + $this->token_rest_controller->register_routes( 'hwp-previews/v1' ); + } ); + } + +} diff --git a/plugins/hwp-previews/src/Post/Data/Post_Data_Model.php b/plugins/hwp-previews/src/Post/Data/Post_Data_Model.php new file mode 100644 index 0000000..3d7e1ac --- /dev/null +++ b/plugins/hwp-previews/src/Post/Data/Post_Data_Model.php @@ -0,0 +1,29 @@ + $data + * @param int $post_id + */ + public function __construct( array $data, int $post_id = 0 ) { + $this->ID = (int) ( $data['ID'] ?? $post_id ); + $this->post_status = (string) ( $data['post_status'] ?? '' ); + $this->post_type = (string) ( $data['post_type'] ?? '' ); + $this->post_name = (string) ( $data['post_name'] ?? '' ); + $this->post_title = (string) ( $data['post_title'] ?? '' ); + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Post/Parent/Contracts/Post_Parent_Manager_Interface.php b/plugins/hwp-previews/src/Post/Parent/Contracts/Post_Parent_Manager_Interface.php new file mode 100644 index 0000000..9002b60 --- /dev/null +++ b/plugins/hwp-previews/src/Post/Parent/Contracts/Post_Parent_Manager_Interface.php @@ -0,0 +1,13 @@ +post_types = $post_types; + $this->post_statuses = $post_statuses; + } + + public function get_post_statuses_as_parent( WP_Post_Type $post_type ): array { + if ( + ! $this->post_types->is_post_type_applicable( $post_type->name ) || + ! $this->post_types->is_hierarchical( $post_type ) + ) { + return []; + } + + return array_intersect( self::POST_STATUSES, $this->post_statuses->get_post_statuses() ); + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Post/Slug/Contracts/Post_Slug_Manager_Interface.php b/plugins/hwp-previews/src/Post/Slug/Contracts/Post_Slug_Manager_Interface.php new file mode 100644 index 0000000..fffa427 --- /dev/null +++ b/plugins/hwp-previews/src/Post/Slug/Contracts/Post_Slug_Manager_Interface.php @@ -0,0 +1,15 @@ +types = $types; + $this->statuses = $statuses; + $this->slug_repository = $slug_repository; + } + + public function force_unique_post_slug( WP_Post $post, wp_rewrite $wp_rewrite ): string { + if ( + ! $post->ID || + ! $this->types->is_post_type_applicable( $post->post_type ) || + ! $this->statuses->is_post_status_applicable( $post->post_status ) + ) { + return ''; + } + + $slug = $post->post_name ?: sanitize_title( $post->post_title, "$post->post_status-$post->ID" ); + $feeds = is_array( $wp_rewrite->feeds ) ? $wp_rewrite->feeds : []; + + return $this->generate_unique_slug( $slug, $post->post_type, $post->ID, array_merge( $feeds, [ 'embed' ] ) ); + } + + /** + * From WordPress core: wp-includes/post.php + * + * @param string $slug + * @param string $post_type + * @param int $post_id + * @param array $reserved_slugs + * + * @return string + */ + public function generate_unique_slug( string $slug, string $post_type, int $post_id, array $reserved_slugs ): string { + $slug = $slug ?: 'undefined'; + + if ( ! $this->slug_repository->is_slug_taken( $slug, $post_type, $post_id ) && ! in_array( $slug, $reserved_slugs, true ) ) { + return $slug; + } + + $suffix = 2; + do { + $new_slug = _truncate_post_slug( $slug, 200 - ( strlen( (string) $suffix ) + 1 ) ) . "-$suffix"; + $suffix ++; + } while ( $this->slug_repository->is_slug_taken( $new_slug, $post_type, $post_id ) ); + + return $new_slug; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Post/Slug/Post_Slug_Repository.php b/plugins/hwp-previews/src/Post/Slug/Post_Slug_Repository.php new file mode 100644 index 0000000..901f695 --- /dev/null +++ b/plugins/hwp-previews/src/Post/Slug/Post_Slug_Repository.php @@ -0,0 +1,23 @@ +wpdb = $wpdb; + } + + public function is_slug_taken( string $slug, string $post_type, int $post_id ): bool { + $query = "SELECT post_name FROM {$this->wpdb->posts} WHERE post_name = %s AND post_type = %s AND ID != %d LIMIT 1"; + + return (bool) $this->wpdb->get_var( $this->wpdb->prepare( $query, $slug, $post_type, $post_id ) ); + } +} diff --git a/plugins/hwp-previews/src/Post/Status/Contracts/Post_Statuses_Config_Interface.php b/plugins/hwp-previews/src/Post/Status/Contracts/Post_Statuses_Config_Interface.php new file mode 100644 index 0000000..47e60ce --- /dev/null +++ b/plugins/hwp-previews/src/Post/Status/Contracts/Post_Statuses_Config_Interface.php @@ -0,0 +1,13 @@ +post_statuses = $post_statuses; + } + + public function get_post_statuses(): array { + return $this->post_statuses; + } + + /** + * TODO: add post status verification to support custom post types in future. Or anything else. + * + * @param string $post_status + * + * @return bool + */ + public function is_post_status_applicable( string $post_status ): bool { + return in_array( $post_status, $this->post_statuses, true ); + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Post/Type/Contracts/Post_Types_Config_Interface.php b/plugins/hwp-previews/src/Post/Type/Contracts/Post_Types_Config_Interface.php new file mode 100644 index 0000000..3f6a8c9 --- /dev/null +++ b/plugins/hwp-previews/src/Post/Type/Contracts/Post_Types_Config_Interface.php @@ -0,0 +1,19 @@ +post_types = $post_types; + } + + public function get_post_types(): array { + return $this->post_types; + } + + public function is_post_type_applicable( string $post_type ): bool { + return in_array( $post_type, $this->post_types, true ) && post_type_exists( $post_type ); + } + + public function is_hierarchical( WP_Post_Type $post_type ): bool { + return $post_type->hierarchical; + } + + public function supports_gutenberg( WP_Post_Type $post_type ): bool { + if ( + empty( $post_type->show_in_rest ) || + empty( $post_type->supports ) || + ! is_array( $post_type->supports ) || + ! in_array( 'editor', $post_type->supports ) + ) { + return false; + } + + if ( ! is_plugin_active( 'classic-editor/classic-editor.php' ) ) { + return true; + } + + $classic_editor_settings = (array) get_option( 'classic-editor-settings', [] ); + + return ! ( + ! empty( $classic_editor_settings['post_types'] ) && + is_array( $classic_editor_settings['post_types'] ) && + in_array( $post_type->name, $classic_editor_settings['post_types'] ) + ); + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Names_Model.php b/plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Names_Model.php new file mode 100644 index 0000000..1ff0905 --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Names_Model.php @@ -0,0 +1,47 @@ +preview = (string) $names['preview']; + } + + $this->token = (string) ( $names['token'] ?? '' ); // can be `token` by default. + $this->post_slug = (string) ( $names['post_slug'] ?? '' ); // can be `slug` by default. + $this->post_id = (string) ( $names['post_id'] ?? '' ); // can be `p` by default. + $this->post_type = (string) ( $names['post_type'] ?? '' ); // can be `type` by default. + $this->post_uri = (string) ( $names['post_uri'] ?? '' ); // can be `uri` by default. + $this->graphql_single = (string) ( $names['graphql_single'] ?? '' ); // can be `gql` by default. + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/Template/Contracts/Preview_Query_Argument_Interface.php b/plugins/hwp-previews/src/Preview/Template/Contracts/Preview_Query_Argument_Interface.php new file mode 100644 index 0000000..5b3fbc4 --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Template/Contracts/Preview_Query_Argument_Interface.php @@ -0,0 +1,12 @@ +types = $types; + $this->statuses = $statuses; + } + + public function resolve_template_path( WP_Post $post, string $template_dir_path, bool $per_post_type = false ): string { + if ( + ! $template_dir_path || + ! $this->types->is_post_type_applicable( $post->post_type ) || + ! $this->statuses->is_post_status_applicable( $post->post_status ) || + ! is_preview() + ) { + return ''; + } + + $template = $template_dir_path . '/hwp-preview.php'; + $template_type = $template_dir_path . '/hwp-preview-' . $post->post_type . '.php'; + + if ( $per_post_type && file_exists( $template_type ) ) { + return $template_type; + } + + return file_exists( $template ) ? $template : ''; + } +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/URL/Contracts/Preview_Parameter_Builder_Interface.php b/plugins/hwp-previews/src/Preview/URL/Contracts/Preview_Parameter_Builder_Interface.php new file mode 100644 index 0000000..a2d0f74 --- /dev/null +++ b/plugins/hwp-previews/src/Preview/URL/Contracts/Preview_Parameter_Builder_Interface.php @@ -0,0 +1,13 @@ +parameter_names = $parameter_names; + } + + /** + * @param WP_Post $post + * @param string $token + * + * @return array + */ + public function build_preview_args( WP_Post $post, string $token ): array { + // Add default preview param. + $args = [ + $this->parameter_names->preview => 'true', + ]; + + // Add post slug param. + if ( $this->parameter_names->post_slug ) { + $args[ $this->parameter_names->post_slug ] = $post->post_name; + } + + // Add post id param. + if ( $this->parameter_names->post_id ) { + $args[ $this->parameter_names->post_id ] = $post->ID; + } + + // Add post type param. + if ( $this->parameter_names->post_type ) { + $args[ $this->parameter_names->post_type ] = $post->post_type; + } + + // Add graphql single name param. + if ( $this->parameter_names->graphql_single ) { + $post_type_object = get_post_type_object( $post->post_type ); + + if ( ! empty( $post_type_object->graphql_single_name ) ) { + $args[ $this->parameter_names->graphql_single ] = ucfirst( $post_type_object->graphql_single_name ); + } + } + + // Add page uri param. + if ( $this->parameter_names->post_uri ) { + $page_uri = (string) get_page_uri( $post->ID ); + + if ($page_uri) { + $args[ $this->parameter_names->post_uri ] = $page_uri; + } + } + + // Add token param. + if ( $this->parameter_names->token && $token ) { + $args[ $this->parameter_names->token ] = $token; + } + + return $args; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php b/plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php new file mode 100644 index 0000000..3fb5b28 --- /dev/null +++ b/plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php @@ -0,0 +1,48 @@ +types = $types; + $this->statuses = $statuses; + } + + public function generate_url( + WP_Post $post, + string $frontend_url, + array $args, + string $draft_route = '' + ): string { + if ( + ! $frontend_url || + ! $this->types->is_post_type_applicable( $post->post_type ) || + ! $this->statuses->is_post_status_applicable( $post->post_status ) + ) { + return ''; + } + + // Format frontend URL to the draft route handler. + if ( $draft_route ) { + $frontend_url = trailingslashit( $frontend_url ) . $draft_route; + } + + if ( $args ) { + return add_query_arg( $args, $frontend_url ); + } + + return $frontend_url; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php b/plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php new file mode 100644 index 0000000..8e02ad9 --- /dev/null +++ b/plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php @@ -0,0 +1,762 @@ + false, + self::PREVIEW_URL => 'http://localhost:3000', + self::ENABLE_UNIQUE_POST_SLUG => true, + self::GENERATE_PREVIEW_TOKEN => true, + self::ENABLE_POST_STATUSES_AS_PARENT => true, + self::TOKEN_AUTH_ENABLED => true, + self::PREVIEW_IN_IFRAME => true, + self::TOKEN_SECRET => '', + self::PREVIEW_PARAMETER_NAMES => [ + 'preview' => 'preview', + 'token' => 'token', + 'post_slug' => 'slug', + 'post_id' => 'p', + 'post_type' => 'type', + 'post_uri' => 'uri', + 'graphql_single' => 'gql' + ], + self::POST_TYPES => [ 'post', 'page' ], + self::POST_STATUSES => [ 'future', 'draft', 'pending', 'private', 'publish' ], + self::DRAFT_ROUTE => '', + self::REST_TOKEN_VERIFICATION => true + ]; + + /** + * Settings + */ + private $settings; + + /** + * Constructor + */ + public function __construct() { + add_action( 'admin_menu', [ $this, 'add_settings_page' ] ); + add_action( 'admin_init', [ $this, 'register_settings' ] ); + + $this->settings = get_option( self::OPTION_NAME, $this->defaults ); + } + + /** + * Add settings page to WP Admin menu + */ + public function add_settings_page() { + add_options_page( + 'Headless Preview Settings', + 'Headless Preview', + 'manage_options', + 'headless-preview', + [ $this, 'render_settings_page' ] + ); + } + + /** + * Register the settings + */ + public function register_settings() { + register_setting( + 'headless-preview', + self::OPTION_NAME, + [ $this, 'sanitize_settings' ] + ); + + // General Settings Section + add_settings_section( + 'general_settings', + 'General Settings', + [ $this, 'render_general_section' ], + 'headless-preview' + ); + + // Preview Settings Section + add_settings_section( + 'preview_settings', + 'Preview Settings', + [ $this, 'render_preview_section' ], + 'headless-preview' + ); + + // Authentication Settings Section + add_settings_section( + 'auth_settings', + 'Authentication Settings', + [ $this, 'render_auth_section' ], + 'headless-preview' + ); + + // URL Parameters Settings Section + add_settings_section( + 'url_params_settings', + 'URL Parameters', + [ $this, 'render_url_params_section' ], + 'headless-preview' + ); + + // Post Types and Statuses Section + add_settings_section( + 'post_settings', + 'Post Types and Statuses', + [ $this, 'render_post_section' ], + 'headless-preview' + ); + + // REST API Settings Section + add_settings_section( + 'rest_settings', + 'REST API Settings', + [ $this, 'render_rest_section' ], + 'headless-preview' + ); + + // General Settings Fields + add_settings_field( + 'preview_url', + 'Headless Frontend URL', + [ $this, 'render_preview_url_field' ], + 'headless-preview', + 'general_settings' + ); + + add_settings_field( + 'draft_route', + 'Draft Route', + [ $this, 'render_draft_route_field' ], + 'headless-preview', + 'general_settings' + ); + + // Preview Settings Fields + add_settings_field( + 'generate_preview_links', + 'Generate Preview Links', + [ $this, 'render_generate_preview_links_field' ], + 'headless-preview', + 'preview_settings' + ); + + add_settings_field( + 'preview_in_iframe', + 'Preview in iFrame', + [ $this, 'render_preview_in_iframe_field' ], + 'headless-preview', + 'preview_settings' + ); + + // Authentication Settings Fields + add_settings_field( + 'generate_preview_token', + 'Generate Preview Token', + [ $this, 'render_generate_preview_token_field' ], + 'headless-preview', + 'auth_settings' + ); + + add_settings_field( + 'token_auth_enabled', + 'Enable Token Authentication', + [ $this, 'render_token_auth_enabled_field' ], + 'headless-preview', + 'auth_settings' + ); + + add_settings_field( + 'token_secret', + 'Token Secret', + [ $this, 'render_token_secret_field' ], + 'headless-preview', + 'auth_settings' + ); + + // URL Parameters Fields + add_settings_field( + 'preview_parameter_names', + 'Parameter Names', + [ $this, 'render_preview_parameter_names_field' ], + 'headless-preview', + 'url_params_settings' + ); + + // Post Types and Statuses Fields + add_settings_field( + 'post_types', + 'Post Types', + [ $this, 'render_post_types_field' ], + 'headless-preview', + 'post_settings' + ); + + add_settings_field( + 'post_statuses', + 'Post Statuses', + [ $this, 'render_post_statuses_field' ], + 'headless-preview', + 'post_settings' + ); + + add_settings_field( + 'enable_unique_post_slug', + 'Enable Unique Post Slug', + [ $this, 'render_enable_unique_post_slug_field' ], + 'headless-preview', + 'post_settings' + ); + + add_settings_field( + 'enable_post_statuses_as_parent', + 'Enable Post Statuses as Parent', + [ $this, 'render_enable_post_statuses_as_parent_field' ], + 'headless-preview', + 'post_settings' + ); + + // REST API Fields + add_settings_field( + 'rest_token_verification', + 'Enable REST Token Verification', + [ $this, 'render_rest_token_verification_field' ], + 'headless-preview', + 'rest_settings' + ); + } + + /** + * Render the General section description + */ + public function render_general_section() { + echo '

Basic settings for your headless frontend configuration.

'; + } + + /** + * Render the Preview section description + */ + public function render_preview_section() { + echo '

Configure how content previews are generated and displayed.

'; + } + + /** + * Render the Authentication section description + */ + public function render_auth_section() { + echo '

Settings related to token generation and authentication for previews.

'; + } + + /** + * Render the URL Parameters section description + */ + public function render_url_params_section() { + echo '

Customize the parameters used in preview URLs. Leave a field empty to omit that parameter.

'; + } + + /** + * Render the Post Types and Statuses section description + */ + public function render_post_section() { + echo '

Configure which post types and statuses should use the headless preview functionality.

'; + } + + /** + * Render the REST API section description + */ + public function render_rest_section() { + echo '

Settings for REST API integration for token verification.

'; + } + + /** + * Render the Generate Preview Links field + */ + public function render_generate_preview_links_field() { + $value = isset( $this->settings['generate_preview_links'] ) ? $this->settings['generate_preview_links'] : false; + ?> + +

+ When enabled, WordPress's default preview links will be replaced with links to your headless frontend. + Should be disabled by default for compatibility with standard WordPress workflows. +

+ settings['preview_url'] ) ? $this->settings['preview_url'] : 'http://localhost:3000'; + ?> + +

+ The base URL of your headless frontend (e.g., Next.js or Nuxt application). + For local development, use something like 'http://localhost:3000'. + For production, use your actual domain like 'https://example.com'. +

+ settings['generate_preview_token'] ) ? $this->settings['generate_preview_token'] : true; + ?> + +

+ When enabled, a JWT token will be included in preview URLs for security purposes. + If AUTH via TOKEN is enabled - it might be used for auth. + Also it might be used for the FE validation of the preview requester. + This helps secure your preview content from unauthorized access. +

+ settings['token_auth_enabled'] ) ? $this->settings['token_auth_enabled'] : true; + ?> + +

+ When enabled, authentication is bypassed for iframe previews. + This setting only works when Preview in iFrame is enabled. + Useful for streamlining the preview experience within the WordPress admin. +

+ settings['preview_in_iframe'] ) ? $this->settings['preview_in_iframe'] : true; + ?> + +

+ When enabled, previews will be displayed in an iframe within the WordPress admin interface, + rather than opening in a new tab. This disables preview link generation as it uses a custom template. + Provides a more seamless editing experience within WordPress. +

+ settings['token_secret'] ) ? $this->settings['token_secret'] : ''; + ?> + +

+ A secret key used to sign JWT tokens. Should be kept secure and not shared. + Changing this will invalidate all existing preview tokens. + For maximum security, use a long, random string of characters. +

+ + + settings['preview_parameter_names'] ) ? $this->settings['preview_parameter_names'] : [ + 'preview' => 'preview', + 'token' => 'token', + 'post_slug' => 'slug', + 'post_id' => 'p', + 'post_type' => 'type', + 'post_uri' => 'uri', + 'graphql_single' => 'gql' + ]; + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterName in URLDescription
Preview + + Indicates this is a preview request
Token + + Authentication token parameter
Post Slug + + The post's slug identifier
Post ID + + The post's numeric ID
Post Type + + The post's content type
Post URI + + The post's full URI path
GraphQL Single + + GraphQL single identifier (if using GraphQL)
+

+ Customize the parameter names used in preview URLs. Leave a field empty to omit that parameter from the URL. + Changes will affect how your frontend receives and processes preview requests. +

+ settings['draft_route'] ) ? $this->settings['draft_route'] : ''; + ?> + +

+ Optional path to append to the frontend URL for draft previews (e.g., 'api/draft' for Next.js). + This route is appended to your frontend URL for handling draft content. + For Next.js Draft Mode, use something like 'api/draft' or 'api/preview'. + For Nuxt, it depends on your preview setup. Leave empty if not using a special draft route. +

+ settings['post_types'] ) ? $this->settings['post_types'] : [ + 'post', + 'page' + ]; + $post_types = get_post_types( [ 'public' => true ], 'objects' ); + ?> +
+ name === 'attachment' ) { + continue; + } + ?> + + +
+

+ Select which post types should have the headless preview functionality enabled. + For custom post types, make sure they support the 'custom-fields' feature to store preview data. +

+ settings['post_statuses'] ) ? $this->settings['post_statuses'] : [ + 'future', + 'draft', + 'pending', + 'private', + 'publish' + ]; + $statuses = [ + 'publish' => 'Published', + 'future' => 'Scheduled', + 'draft' => 'Draft', + 'pending' => 'Pending Review', + 'private' => 'Private' + ]; + ?> +
+ $label ) : ?> + + +
+

+ Select which post statuses should be supported for previews. + This determines which types of content can be viewed in preview mode. + For example, 'draft' allows previewing unpublished content, while 'private' allows previewing + content that's only visible to logged-in users on the WordPress side. +

+ settings['rest_token_verification'] ) ? $this->settings['rest_token_verification'] : false; + ?> + +

+ When enabled, a custom REST API endpoint will be created at:
+ /wp-json/hwp-previews/v1/verify-preview-token?token=<string>
+ This can be used by your frontend to verify that preview tokens are valid before showing preview content. + This helps implement secure previewing in your headless frontend application. +

+ settings['enable_unique_post_slug'] ) ? $this->settings['enable_unique_post_slug'] : true; + ?> + +

+ When enabled, the plugin ensures that post slugs remain unique across all post types and statuses in the + preview context. + This is important for proper URL generation and navigation in your headless frontend, especially when + dealing with + draft content that might have duplicate slugs. Enabling this setting helps prevent URL conflicts and ensures + each piece of content has a unique identifier in your frontend application. +

+ settings['enable_post_statuses_as_parent'] ) ? $this->settings['enable_post_statuses_as_parent'] : true; + ?> + +

+ When enabled, posts with non-published statuses (like drafts or pending) can be used as parent posts in + hierarchical structures. + This is particularly useful for previewing complex content structures where parent-child relationships + exist, and you need to preview + changes to the hierarchy before publishing. This setting allows you to create and preview nested page + structures where parent pages + may still be in draft or other non-published states. +

+ $value ) { + $sanitized['preview_parameter_names'][ $key ] = sanitize_text_field( $value ); + } + } + + // Post types + $sanitized['post_types'] = []; + if ( isset( $input['post_types'] ) && is_array( $input['post_types'] ) ) { + $valid_post_types = array_keys( get_post_types( [ 'public' => true ] ) ); + foreach ( $input['post_types'] as $post_type ) { + if ( in_array( $post_type, $valid_post_types ) ) { + $sanitized['post_types'][] = $post_type; + } + } + } + + // Post statuses + $sanitized['post_statuses'] = []; + if ( isset( $input['post_statuses'] ) && is_array( $input['post_statuses'] ) ) { + $valid_statuses = [ 'publish', 'future', 'draft', 'pending', 'private' ]; + foreach ( $input['post_statuses'] as $status ) { + if ( in_array( $status, $valid_statuses ) ) { + $sanitized['post_statuses'][] = $status; + } + } + } + + return $sanitized; + } + + /** + * Render the settings page + */ + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + ?> +
+

+
+

+ The settings code/UX should be improved! +

+

+ About Headless Preview: These settings configure how WordPress previews content + on your headless frontend. If you're using WordPress as a headless CMS with a separate frontend + (like Next.js, Nuxt, etc.), these options allow you to customize how preview links are generated + and authenticated. +

+
+
+ +
+ +
+

Usage Information

+

After saving these settings, you may need to:

+
    +
  1. Configure your frontend application to handle preview URLs with the parameters defined above +
  2. +
  3. Set up authentication handling in your frontend to validate preview tokens
  4. +
  5. If using iFrame previews, ensure your frontend allows being displayed in an iframe (check + X-Frame-Options headers) +
  6. +
+
+
+ settings[ $key ] ) ? $this->settings[ $key ] : $default; + } +} diff --git a/plugins/hwp-previews/src/Shared/Model.php b/plugins/hwp-previews/src/Shared/Model.php new file mode 100644 index 0000000..dcf2354 --- /dev/null +++ b/plugins/hwp-previews/src/Shared/Model.php @@ -0,0 +1,24 @@ +verifier = $verifier; + } + + public function determine_preview_user( string $token ): int { + $token = $this->verifier->verify_token( $token, self::PREVIEW_NONCE_ACTION ); + if ( ! $token || empty( $token->data->user->id ) ) { + return 0; + } + + return $token->data->user->id; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Token/Extractor/Contracts/Token_Extractor_Interface.php b/plugins/hwp-previews/src/Token/Extractor/Contracts/Token_Extractor_Interface.php new file mode 100644 index 0000000..b8d0db3 --- /dev/null +++ b/plugins/hwp-previews/src/Token/Extractor/Contracts/Token_Extractor_Interface.php @@ -0,0 +1,11 @@ +token_manager = $token_manager; + } + + public function generate_token( array $data, string $nonce, int $exp = 360 ): string { + $data['data']['nonce'] = wp_create_nonce( $nonce ); + + return $this->token_manager->generate_token( $data, $exp ); + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Token/Manager/Contracts/Token_Manager_Interface.php b/plugins/hwp-previews/src/Token/Manager/Contracts/Token_Manager_Interface.php new file mode 100644 index 0000000..f5104c6 --- /dev/null +++ b/plugins/hwp-previews/src/Token/Manager/Contracts/Token_Manager_Interface.php @@ -0,0 +1,29 @@ +secret = $secret; + $this->alg = $alg; + } + + public function generate_token( array $data, int $expiration = 360 ): string { + $issued_at = time(); + $token_data = array_merge( $data, [ + 'iat' => $issued_at, + 'exp' => $issued_at + $expiration, + 'iss' => get_site_url(), + ] ); + + try { + return JWT::encode( $token_data, $this->secret, $this->alg ); + } catch ( Exception $e ) { + // TODO: Exception handling. + return ''; + } + } + + /** + * Todo: proper exception handling. + * + * @param string $token + * + * @return stdClass|null + */ + public function verify_token( string $token, int $leeway = 60 ): ?stdClass { + if ( empty( $token ) ) { + return null; + } + + JWT::$leeway = $leeway; + + try { + $decoded = JWT::decode( $token, new Key( $this->secret, $this->alg ) ); + + // Check if the token has expired. + if ( isset( $decoded->exp ) && time() > $decoded->exp ) { + return null; + } + + // Check if the token was issued by this server. + if ( isset( $token->iss ) && ! in_array( $token->iss, (array) get_bloginfo('url')) ) { + // See https://github.com/wp-graphql/wp-graphql-jwt-authentication/issues/111 + add_filter( 'graphql_response_status_code', fn() => 401 ); + + return null; + } + + // TODO: Any more checks here? + + return $decoded; + } catch ( Exception $e ) { + return null; + } + } + + public function refresh_token( string $token, int $expiration = 360 ): ?string { + $token_data = $this->verify_token( $token ); + + if ( null === $token_data ) { + return null; + } + + // Convert stdClass to array + $data = json_decode( json_encode( $token_data ), true ); + + // Remove timing claims that will be re-added + unset( $data['iat'] ); + unset( $data['exp'] ); + unset( $data['iss'] ); + + $refreshed_token = $this->generate_token( $data, $expiration ); + + return empty( $refreshed_token ) ? null : $refreshed_token; + } +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Token/REST/Contracts/Token_REST_Controller_Interface.php b/plugins/hwp-previews/src/Token/REST/Contracts/Token_REST_Controller_Interface.php new file mode 100644 index 0000000..99ca7ae --- /dev/null +++ b/plugins/hwp-previews/src/Token/REST/Contracts/Token_REST_Controller_Interface.php @@ -0,0 +1,11 @@ +verifier = $verifier; + $this->nonce_action = $nonce_action; + } + + public function register_routes( string $namespace ): void { + $result = register_rest_route( $namespace, '/verify-preview-token', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'route_callback' ], + 'permission_callback' => '__return_true', // todo: is that correct? + 'args' => [ + self::TOKEN_PARAMETER => [ + 'required' => true, + 'validate_callback' => function ( $param ) { + return is_string( $param ) && ! empty( $param ); + } + ] + ] + ] ); + + if ( ! $result ) { + // todo: Do something with the error? + } + } + + public function route_callback( WP_REST_Request $request ): WP_REST_Response { + if ( ! $request->has_valid_params() ) { + return new WP_REST_Response( [ 'error' => 'Body does not have valid parameters' ], 400 ); + } + + $token = (string) json_decode( (string) $request->get_param( self::TOKEN_PARAMETER ) ); + + if ( ! $token ) { + return new WP_REST_Response( [ 'error' => 'A token is required' ], 400 ); + } + + $success = $this->verifier->verify_token( $token, $this->nonce_action ); + +// if ( ! $success ) { +// return new WP_REST_Response( [ 'valid' => false, 'error' => 'The token is not valid.' ], 500 ); +// } + + return new WP_REST_Response( [ 'valid' => true, 'data' => [ 'success' => true ] ], 200 ); + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Token/Verifier/Contracts/Token_Verifier_Interface.php b/plugins/hwp-previews/src/Token/Verifier/Contracts/Token_Verifier_Interface.php new file mode 100644 index 0000000..cb7a494 --- /dev/null +++ b/plugins/hwp-previews/src/Token/Verifier/Contracts/Token_Verifier_Interface.php @@ -0,0 +1,13 @@ +token_manager = $token_manager; + } + + public function verify_token( string $token, string $nonce_action ): ?stdClass { + $token = $this->token_manager->verify_token( $token ); + + if ( + ! $token || + empty( $token->data->nonce ) || + wp_verify_nonce( $token->data->nonce, $nonce_action ) !== 1 + ) { + return null; + } + + return $token; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/templates/hwp-preview.php b/plugins/hwp-previews/templates/hwp-preview.php new file mode 100644 index 0000000..9adefb9 --- /dev/null +++ b/plugins/hwp-previews/templates/hwp-preview.php @@ -0,0 +1,41 @@ + + +
+ +
+ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/plugins/hwp-previews/vendor/composer/InstalledVersions.php b/plugins/hwp-previews/vendor/composer/InstalledVersions.php new file mode 100644 index 0000000..07b32ed --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/InstalledVersions.php @@ -0,0 +1,362 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Composer\Autoload\ClassLoader; +use Composer\Semver\VersionParser; + +/** + * This class is copied in every Composer installed project and available to all + * + * See also https://getcomposer.org/doc/07-runtime.md#installed-versions + * + * To require its presence, you can require `composer-runtime-api ^2.0` + * + * @final + */ +class InstalledVersions +{ + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = array(); + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = array(); + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = array(); + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__ . '/installed.php'; + } else { + self::$installed = array(); + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = array(); + $copiedLocalDir = false; + + if (self::$canGetVendors) { + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { + self::$installed = $required; + $copiedLocalDir = true; + } + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array() && !$copiedLocalDir) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/plugins/hwp-previews/vendor/composer/LICENSE b/plugins/hwp-previews/vendor/composer/LICENSE new file mode 100644 index 0000000..f27399a --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/plugins/hwp-previews/vendor/composer/autoload_classmap.php b/plugins/hwp-previews/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000..0fb0a2c --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/autoload_classmap.php @@ -0,0 +1,10 @@ + $vendorDir . '/composer/InstalledVersions.php', +); diff --git a/plugins/hwp-previews/vendor/composer/autoload_namespaces.php b/plugins/hwp-previews/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000..15a2ff3 --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($baseDir . '/src'), + 'Firebase\\JWT\\' => array($vendorDir . '/firebase/php-jwt/src'), +); diff --git a/plugins/hwp-previews/vendor/composer/autoload_real.php b/plugins/hwp-previews/vendor/composer/autoload_real.php new file mode 100644 index 0000000..3d6ac63 --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/autoload_real.php @@ -0,0 +1,38 @@ +register(true); + + return $loader; + } +} diff --git a/plugins/hwp-previews/vendor/composer/autoload_static.php b/plugins/hwp-previews/vendor/composer/autoload_static.php new file mode 100644 index 0000000..c4f7253 --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/autoload_static.php @@ -0,0 +1,44 @@ + + array ( + 'HWP\\Previews\\' => 13, + ), + 'F' => + array ( + 'Firebase\\JWT\\' => 13, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'HWP\\Previews\\' => + array ( + 0 => __DIR__ . '/../..' . '/src', + ), + 'Firebase\\JWT\\' => + array ( + 0 => __DIR__ . '/..' . '/firebase/php-jwt/src', + ), + ); + + public static $classMap = array ( + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit72736c17dd331a5ebe8e0bc4565a896f::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit72736c17dd331a5ebe8e0bc4565a896f::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInit72736c17dd331a5ebe8e0bc4565a896f::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/plugins/hwp-previews/vendor/composer/installed.json b/plugins/hwp-previews/vendor/composer/installed.json new file mode 100644 index 0000000..aedce0f --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/installed.json @@ -0,0 +1,72 @@ +{ + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.11.0", + "version_normalized": "6.11.0.0", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "time": "2025-01-23T05:11:06+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.0" + }, + "install-path": "../firebase/php-jwt" + } + ], + "dev": true, + "dev-package-names": [] +} diff --git a/plugins/hwp-previews/vendor/composer/installed.php b/plugins/hwp-previews/vendor/composer/installed.php new file mode 100644 index 0000000..3fc591f --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/installed.php @@ -0,0 +1,32 @@ + array( + 'name' => 'wpengine/hwp-previews', + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'wordpress-plugin', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev' => true, + ), + 'versions' => array( + 'firebase/php-jwt' => array( + 'pretty_version' => 'v6.11.0', + 'version' => '6.11.0.0', + 'reference' => '8f718f4dfc9c5d5f0c994cdfd103921b43592712', + 'type' => 'library', + 'install_path' => __DIR__ . '/../firebase/php-jwt', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'wpengine/hwp-previews' => array( + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'wordpress-plugin', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev_requirement' => false, + ), + ), +); diff --git a/plugins/hwp-previews/vendor/composer/platform_check.php b/plugins/hwp-previews/vendor/composer/platform_check.php new file mode 100644 index 0000000..adfb472 --- /dev/null +++ b/plugins/hwp-previews/vendor/composer/platform_check.php @@ -0,0 +1,26 @@ += 80000)) { + $issues[] = 'Your Composer dependencies require a PHP version ">= 8.0.0". You are running ' . PHP_VERSION . '.'; +} + +if ($issues) { + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL); + } elseif (!headers_sent()) { + echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; + } + } + trigger_error( + 'Composer detected issues in your platform: ' . implode(' ', $issues), + E_USER_ERROR + ); +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/CHANGELOG.md b/plugins/hwp-previews/vendor/firebase/php-jwt/CHANGELOG.md new file mode 100644 index 0000000..01fcc07 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/CHANGELOG.md @@ -0,0 +1,198 @@ +# Changelog + +## [6.11.0](https://github.com/firebase/php-jwt/compare/v6.10.2...v6.11.0) (2025-01-23) + + +### Features + +* support octet typed JWK ([#587](https://github.com/firebase/php-jwt/issues/587)) ([7cb8a26](https://github.com/firebase/php-jwt/commit/7cb8a265fa81edf2fa6ef8098f5bc5ae573c33ad)) + + +### Bug Fixes + +* refactor constructor Key to use PHP 8.0 syntax ([#577](https://github.com/firebase/php-jwt/issues/577)) ([29fa2ce](https://github.com/firebase/php-jwt/commit/29fa2ce9e0582cd397711eec1e80c05ce20fabca)) + +## [6.10.2](https://github.com/firebase/php-jwt/compare/v6.10.1...v6.10.2) (2024-11-24) + + +### Bug Fixes + +* Mitigate PHP8.4 deprecation warnings ([#570](https://github.com/firebase/php-jwt/issues/570)) ([76808fa](https://github.com/firebase/php-jwt/commit/76808fa227f3811aa5cdb3bf81233714b799a5b5)) +* support php 8.4 ([#583](https://github.com/firebase/php-jwt/issues/583)) ([e3d68b0](https://github.com/firebase/php-jwt/commit/e3d68b044421339443c74199edd020e03fb1887e)) + +## [6.10.1](https://github.com/firebase/php-jwt/compare/v6.10.0...v6.10.1) (2024-05-18) + + +### Bug Fixes + +* ensure ratelimit expiry is set every time ([#556](https://github.com/firebase/php-jwt/issues/556)) ([09cb208](https://github.com/firebase/php-jwt/commit/09cb2081c2c3bc0f61e2f2a5fbea5741f7498648)) +* ratelimit cache expiration ([#550](https://github.com/firebase/php-jwt/issues/550)) ([dda7250](https://github.com/firebase/php-jwt/commit/dda725033585ece30ff8cae8937320d7e9f18bae)) + +## [6.10.0](https://github.com/firebase/php-jwt/compare/v6.9.0...v6.10.0) (2023-11-28) + + +### Features + +* allow typ header override ([#546](https://github.com/firebase/php-jwt/issues/546)) ([79cb30b](https://github.com/firebase/php-jwt/commit/79cb30b729a22931b2fbd6b53f20629a83031ba9)) + +## [6.9.0](https://github.com/firebase/php-jwt/compare/v6.8.1...v6.9.0) (2023-10-04) + + +### Features + +* add payload to jwt exception ([#521](https://github.com/firebase/php-jwt/issues/521)) ([175edf9](https://github.com/firebase/php-jwt/commit/175edf958bb61922ec135b2333acf5622f2238a2)) + +## [6.8.1](https://github.com/firebase/php-jwt/compare/v6.8.0...v6.8.1) (2023-07-14) + + +### Bug Fixes + +* accept float claims but round down to ignore them ([#492](https://github.com/firebase/php-jwt/issues/492)) ([3936842](https://github.com/firebase/php-jwt/commit/39368423beeaacb3002afa7dcb75baebf204fe7e)) +* different BeforeValidException messages for nbf and iat ([#526](https://github.com/firebase/php-jwt/issues/526)) ([0a53cf2](https://github.com/firebase/php-jwt/commit/0a53cf2986e45c2bcbf1a269f313ebf56a154ee4)) + +## [6.8.0](https://github.com/firebase/php-jwt/compare/v6.7.0...v6.8.0) (2023-06-14) + + +### Features + +* add support for P-384 curve ([#515](https://github.com/firebase/php-jwt/issues/515)) ([5de4323](https://github.com/firebase/php-jwt/commit/5de4323f4baf4d70bca8663bd87682a69c656c3d)) + + +### Bug Fixes + +* handle invalid http responses ([#508](https://github.com/firebase/php-jwt/issues/508)) ([91c39c7](https://github.com/firebase/php-jwt/commit/91c39c72b22fc3e1191e574089552c1f2041c718)) + +## [6.7.0](https://github.com/firebase/php-jwt/compare/v6.6.0...v6.7.0) (2023-06-14) + + +### Features + +* add ed25519 support to JWK (public keys) ([#452](https://github.com/firebase/php-jwt/issues/452)) ([e53979a](https://github.com/firebase/php-jwt/commit/e53979abae927de916a75b9d239cfda8ce32be2a)) + +## [6.6.0](https://github.com/firebase/php-jwt/compare/v6.5.0...v6.6.0) (2023-06-13) + + +### Features + +* allow get headers when decoding token ([#442](https://github.com/firebase/php-jwt/issues/442)) ([fb85f47](https://github.com/firebase/php-jwt/commit/fb85f47cfaeffdd94faf8defdf07164abcdad6c3)) + + +### Bug Fixes + +* only check iat if nbf is not used ([#493](https://github.com/firebase/php-jwt/issues/493)) ([398ccd2](https://github.com/firebase/php-jwt/commit/398ccd25ea12fa84b9e4f1085d5ff448c21ec797)) + +## [6.5.0](https://github.com/firebase/php-jwt/compare/v6.4.0...v6.5.0) (2023-05-12) + + +### Bug Fixes + +* allow KID of '0' ([#505](https://github.com/firebase/php-jwt/issues/505)) ([9dc46a9](https://github.com/firebase/php-jwt/commit/9dc46a9c3e5801294249cfd2554c5363c9f9326a)) + + +### Miscellaneous Chores + +* drop support for PHP 7.3 ([#495](https://github.com/firebase/php-jwt/issues/495)) + +## [6.4.0](https://github.com/firebase/php-jwt/compare/v6.3.2...v6.4.0) (2023-02-08) + + +### Features + +* add support for W3C ES256K ([#462](https://github.com/firebase/php-jwt/issues/462)) ([213924f](https://github.com/firebase/php-jwt/commit/213924f51936291fbbca99158b11bd4ae56c2c95)) +* improve caching by only decoding jwks when necessary ([#486](https://github.com/firebase/php-jwt/issues/486)) ([78d3ed1](https://github.com/firebase/php-jwt/commit/78d3ed1073553f7d0bbffa6c2010009a0d483d5c)) + +## [6.3.2](https://github.com/firebase/php-jwt/compare/v6.3.1...v6.3.2) (2022-11-01) + + +### Bug Fixes + +* check kid before using as array index ([bad1b04](https://github.com/firebase/php-jwt/commit/bad1b040d0c736bbf86814c6b5ae614f517cf7bd)) + +## [6.3.1](https://github.com/firebase/php-jwt/compare/v6.3.0...v6.3.1) (2022-11-01) + + +### Bug Fixes + +* casing of GET for PSR compat ([#451](https://github.com/firebase/php-jwt/issues/451)) ([60b52b7](https://github.com/firebase/php-jwt/commit/60b52b71978790eafcf3b95cfbd83db0439e8d22)) +* string interpolation format for php 8.2 ([#446](https://github.com/firebase/php-jwt/issues/446)) ([2e07d8a](https://github.com/firebase/php-jwt/commit/2e07d8a1524d12b69b110ad649f17461d068b8f2)) + +## 6.3.0 / 2022-07-15 + + - Added ES256 support to JWK parsing ([#399](https://github.com/firebase/php-jwt/pull/399)) + - Fixed potential caching error in `CachedKeySet` by caching jwks as strings ([#435](https://github.com/firebase/php-jwt/pull/435)) + +## 6.2.0 / 2022-05-14 + + - Added `CachedKeySet` ([#397](https://github.com/firebase/php-jwt/pull/397)) + - Added `$defaultAlg` parameter to `JWT::parseKey` and `JWT::parseKeySet` ([#426](https://github.com/firebase/php-jwt/pull/426)). + +## 6.1.0 / 2022-03-23 + + - Drop support for PHP 5.3, 5.4, 5.5, 5.6, and 7.0 + - Add parameter typing and return types where possible + +## 6.0.0 / 2022-01-24 + + - **Backwards-Compatibility Breaking Changes**: See the [Release Notes](https://github.com/firebase/php-jwt/releases/tag/v6.0.0) for more information. + - New Key object to prevent key/algorithm type confusion (#365) + - Add JWK support (#273) + - Add ES256 support (#256) + - Add ES384 support (#324) + - Add Ed25519 support (#343) + +## 5.0.0 / 2017-06-26 +- Support RS384 and RS512. + See [#117](https://github.com/firebase/php-jwt/pull/117). Thanks [@joostfaassen](https://github.com/joostfaassen)! +- Add an example for RS256 openssl. + See [#125](https://github.com/firebase/php-jwt/pull/125). Thanks [@akeeman](https://github.com/akeeman)! +- Detect invalid Base64 encoding in signature. + See [#162](https://github.com/firebase/php-jwt/pull/162). Thanks [@psignoret](https://github.com/psignoret)! +- Update `JWT::verify` to handle OpenSSL errors. + See [#159](https://github.com/firebase/php-jwt/pull/159). Thanks [@bshaffer](https://github.com/bshaffer)! +- Add `array` type hinting to `decode` method + See [#101](https://github.com/firebase/php-jwt/pull/101). Thanks [@hywak](https://github.com/hywak)! +- Add all JSON error types. + See [#110](https://github.com/firebase/php-jwt/pull/110). Thanks [@gbalduzzi](https://github.com/gbalduzzi)! +- Bugfix 'kid' not in given key list. + See [#129](https://github.com/firebase/php-jwt/pull/129). Thanks [@stampycode](https://github.com/stampycode)! +- Miscellaneous cleanup, documentation and test fixes. + See [#107](https://github.com/firebase/php-jwt/pull/107), [#115](https://github.com/firebase/php-jwt/pull/115), + [#160](https://github.com/firebase/php-jwt/pull/160), [#161](https://github.com/firebase/php-jwt/pull/161), and + [#165](https://github.com/firebase/php-jwt/pull/165). Thanks [@akeeman](https://github.com/akeeman), + [@chinedufn](https://github.com/chinedufn), and [@bshaffer](https://github.com/bshaffer)! + +## 4.0.0 / 2016-07-17 +- Add support for late static binding. See [#88](https://github.com/firebase/php-jwt/pull/88) for details. Thanks to [@chappy84](https://github.com/chappy84)! +- Use static `$timestamp` instead of `time()` to improve unit testing. See [#93](https://github.com/firebase/php-jwt/pull/93) for details. Thanks to [@josephmcdermott](https://github.com/josephmcdermott)! +- Fixes to exceptions classes. See [#81](https://github.com/firebase/php-jwt/pull/81) for details. Thanks to [@Maks3w](https://github.com/Maks3w)! +- Fixes to PHPDoc. See [#76](https://github.com/firebase/php-jwt/pull/76) for details. Thanks to [@akeeman](https://github.com/akeeman)! + +## 3.0.0 / 2015-07-22 +- Minimum PHP version updated from `5.2.0` to `5.3.0`. +- Add `\Firebase\JWT` namespace. See +[#59](https://github.com/firebase/php-jwt/pull/59) for details. Thanks to +[@Dashron](https://github.com/Dashron)! +- Require a non-empty key to decode and verify a JWT. See +[#60](https://github.com/firebase/php-jwt/pull/60) for details. Thanks to +[@sjones608](https://github.com/sjones608)! +- Cleaner documentation blocks in the code. See +[#62](https://github.com/firebase/php-jwt/pull/62) for details. Thanks to +[@johanderuijter](https://github.com/johanderuijter)! + +## 2.2.0 / 2015-06-22 +- Add support for adding custom, optional JWT headers to `JWT::encode()`. See +[#53](https://github.com/firebase/php-jwt/pull/53/files) for details. Thanks to +[@mcocaro](https://github.com/mcocaro)! + +## 2.1.0 / 2015-05-20 +- Add support for adding a leeway to `JWT:decode()` that accounts for clock skew +between signing and verifying entities. Thanks to [@lcabral](https://github.com/lcabral)! +- Add support for passing an object implementing the `ArrayAccess` interface for +`$keys` argument in `JWT::decode()`. Thanks to [@aztech-dev](https://github.com/aztech-dev)! + +## 2.0.0 / 2015-04-01 +- **Note**: It is strongly recommended that you update to > v2.0.0 to address + known security vulnerabilities in prior versions when both symmetric and + asymmetric keys are used together. +- Update signature for `JWT::decode(...)` to require an array of supported + algorithms to use when verifying token signatures. diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/LICENSE b/plugins/hwp-previews/vendor/firebase/php-jwt/LICENSE new file mode 100644 index 0000000..11c0146 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2011, Neuman Vong + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the copyright holder nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/README.md b/plugins/hwp-previews/vendor/firebase/php-jwt/README.md new file mode 100644 index 0000000..0425269 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/README.md @@ -0,0 +1,425 @@ +![Build Status](https://github.com/firebase/php-jwt/actions/workflows/tests.yml/badge.svg) +[![Latest Stable Version](https://poser.pugx.org/firebase/php-jwt/v/stable)](https://packagist.org/packages/firebase/php-jwt) +[![Total Downloads](https://poser.pugx.org/firebase/php-jwt/downloads)](https://packagist.org/packages/firebase/php-jwt) +[![License](https://poser.pugx.org/firebase/php-jwt/license)](https://packagist.org/packages/firebase/php-jwt) + +PHP-JWT +======= +A simple library to encode and decode JSON Web Tokens (JWT) in PHP, conforming to [RFC 7519](https://tools.ietf.org/html/rfc7519). + +Installation +------------ + +Use composer to manage your dependencies and download PHP-JWT: + +```bash +composer require firebase/php-jwt +``` + +Optionally, install the `paragonie/sodium_compat` package from composer if your +php env does not have libsodium installed: + +```bash +composer require paragonie/sodium_compat +``` + +Example +------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +/** + * IMPORTANT: + * You must specify supported algorithms for your application. See + * https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40 + * for a list of spec-compliant algorithms. + */ +$jwt = JWT::encode($payload, $key, 'HS256'); +$decoded = JWT::decode($jwt, new Key($key, 'HS256')); +print_r($decoded); + +// Pass a stdClass in as the third parameter to get the decoded header values +$headers = new stdClass(); +$decoded = JWT::decode($jwt, new Key($key, 'HS256'), $headers); +print_r($headers); + +/* + NOTE: This will now be an object instead of an associative array. To get + an associative array, you will need to cast it as such: +*/ + +$decoded_array = (array) $decoded; + +/** + * You can add a leeway to account for when there is a clock skew times between + * the signing and verifying servers. It is recommended that this leeway should + * not be bigger than a few minutes. + * + * Source: http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#nbfDef + */ +JWT::$leeway = 60; // $leeway in seconds +$decoded = JWT::decode($jwt, new Key($key, 'HS256')); +``` +Example encode/decode headers +------- +Decoding the JWT headers without verifying the JWT first is NOT recommended, and is not supported by +this library. This is because without verifying the JWT, the header values could have been tampered with. +Any value pulled from an unverified header should be treated as if it could be any string sent in from an +attacker. If this is something you still want to do in your application for whatever reason, it's possible to +decode the header values manually simply by calling `json_decode` and `base64_decode` on the JWT +header part: +```php +use Firebase\JWT\JWT; + +$key = 'example_key'; +$payload = [ + 'iss' => 'http://example.org', + 'aud' => 'http://example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$headers = [ + 'x-forwarded-for' => 'www.google.com' +]; + +// Encode headers in the JWT string +$jwt = JWT::encode($payload, $key, 'HS256', null, $headers); + +// Decode headers from the JWT string WITHOUT validation +// **IMPORTANT**: This operation is vulnerable to attacks, as the JWT has not yet been verified. +// These headers could be any value sent by an attacker. +list($headersB64, $payloadB64, $sig) = explode('.', $jwt); +$decoded = json_decode(base64_decode($headersB64), true); + +print_r($decoded); +``` +Example with RS256 (openssl) +---------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +$privateKey = << 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt = JWT::encode($payload, $privateKey, 'RS256'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256')); + +/* + NOTE: This will now be an object instead of an associative array. To get + an associative array, you will need to cast it as such: +*/ + +$decoded_array = (array) $decoded; +echo "Decode:\n" . print_r($decoded_array, true) . "\n"; +``` + +Example with a passphrase +------------------------- + +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Your passphrase +$passphrase = '[YOUR_PASSPHRASE]'; + +// Your private key file with passphrase +// Can be generated with "ssh-keygen -t rsa -m pem" +$privateKeyFile = '/path/to/key-with-passphrase.pem'; + +// Create a private key of type "resource" +$privateKey = openssl_pkey_get_private( + file_get_contents($privateKeyFile), + $passphrase +); + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt = JWT::encode($payload, $privateKey, 'RS256'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +// Get public key from the private key, or pull from from a file. +$publicKey = openssl_pkey_get_details($privateKey)['key']; + +$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256')); +echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; +``` + +Example with EdDSA (libsodium and Ed25519 signature) +---------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Public and private keys are expected to be Base64 encoded. The last +// non-empty line is used so that keys can be generated with +// sodium_crypto_sign_keypair(). The secret keys generated by other tools may +// need to be adjusted to match the input expected by libsodium. + +$keyPair = sodium_crypto_sign_keypair(); + +$privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair)); + +$publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair)); + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt = JWT::encode($payload, $privateKey, 'EdDSA'); +echo "Encode:\n" . print_r($jwt, true) . "\n"; + +$decoded = JWT::decode($jwt, new Key($publicKey, 'EdDSA')); +echo "Decode:\n" . print_r((array) $decoded, true) . "\n"; +```` + +Example with multiple keys +-------------------------- +```php +use Firebase\JWT\JWT; +use Firebase\JWT\Key; + +// Example RSA keys from previous example +// $privateKey1 = '...'; +// $publicKey1 = '...'; + +// Example EdDSA keys from previous example +// $privateKey2 = '...'; +// $publicKey2 = '...'; + +$payload = [ + 'iss' => 'example.org', + 'aud' => 'example.com', + 'iat' => 1356999524, + 'nbf' => 1357000000 +]; + +$jwt1 = JWT::encode($payload, $privateKey1, 'RS256', 'kid1'); +$jwt2 = JWT::encode($payload, $privateKey2, 'EdDSA', 'kid2'); +echo "Encode 1:\n" . print_r($jwt1, true) . "\n"; +echo "Encode 2:\n" . print_r($jwt2, true) . "\n"; + +$keys = [ + 'kid1' => new Key($publicKey1, 'RS256'), + 'kid2' => new Key($publicKey2, 'EdDSA'), +]; + +$decoded1 = JWT::decode($jwt1, $keys); +$decoded2 = JWT::decode($jwt2, $keys); + +echo "Decode 1:\n" . print_r((array) $decoded1, true) . "\n"; +echo "Decode 2:\n" . print_r((array) $decoded2, true) . "\n"; +``` + +Using JWKs +---------- + +```php +use Firebase\JWT\JWK; +use Firebase\JWT\JWT; + +// Set of keys. The "keys" key is required. For example, the JSON response to +// this endpoint: https://www.gstatic.com/iap/verify/public_key-jwk +$jwks = ['keys' => []]; + +// JWK::parseKeySet($jwks) returns an associative array of **kid** to Firebase\JWT\Key +// objects. Pass this as the second parameter to JWT::decode. +JWT::decode($payload, JWK::parseKeySet($jwks)); +``` + +Using Cached Key Sets +--------------------- + +The `CachedKeySet` class can be used to fetch and cache JWKS (JSON Web Key Sets) from a public URI. +This has the following advantages: + +1. The results are cached for performance. +2. If an unrecognized key is requested, the cache is refreshed, to accomodate for key rotation. +3. If rate limiting is enabled, the JWKS URI will not make more than 10 requests a second. + +```php +use Firebase\JWT\CachedKeySet; +use Firebase\JWT\JWT; + +// The URI for the JWKS you wish to cache the results from +$jwksUri = 'https://www.gstatic.com/iap/verify/public_key-jwk'; + +// Create an HTTP client (can be any PSR-7 compatible HTTP client) +$httpClient = new GuzzleHttp\Client(); + +// Create an HTTP request factory (can be any PSR-17 compatible HTTP request factory) +$httpFactory = new GuzzleHttp\Psr\HttpFactory(); + +// Create a cache item pool (can be any PSR-6 compatible cache item pool) +$cacheItemPool = Phpfastcache\CacheManager::getInstance('files'); + +$keySet = new CachedKeySet( + $jwksUri, + $httpClient, + $httpFactory, + $cacheItemPool, + null, // $expiresAfter int seconds to set the JWKS to expire + true // $rateLimit true to enable rate limit of 10 RPS on lookup of invalid keys +); + +$jwt = 'eyJhbGci...'; // Some JWT signed by a key from the $jwkUri above +$decoded = JWT::decode($jwt, $keySet); +``` + +Miscellaneous +------------- + +#### Exception Handling + +When a call to `JWT::decode` is invalid, it will throw one of the following exceptions: + +```php +use Firebase\JWT\JWT; +use Firebase\JWT\SignatureInvalidException; +use Firebase\JWT\BeforeValidException; +use Firebase\JWT\ExpiredException; +use DomainException; +use InvalidArgumentException; +use UnexpectedValueException; + +try { + $decoded = JWT::decode($payload, $keys); +} catch (InvalidArgumentException $e) { + // provided key/key-array is empty or malformed. +} catch (DomainException $e) { + // provided algorithm is unsupported OR + // provided key is invalid OR + // unknown error thrown in openSSL or libsodium OR + // libsodium is required but not available. +} catch (SignatureInvalidException $e) { + // provided JWT signature verification failed. +} catch (BeforeValidException $e) { + // provided JWT is trying to be used before "nbf" claim OR + // provided JWT is trying to be used before "iat" claim. +} catch (ExpiredException $e) { + // provided JWT is trying to be used after "exp" claim. +} catch (UnexpectedValueException $e) { + // provided JWT is malformed OR + // provided JWT is missing an algorithm / using an unsupported algorithm OR + // provided JWT algorithm does not match provided key OR + // provided key ID in key/key-array is empty or invalid. +} +``` + +All exceptions in the `Firebase\JWT` namespace extend `UnexpectedValueException`, and can be simplified +like this: + +```php +use Firebase\JWT\JWT; +use UnexpectedValueException; +try { + $decoded = JWT::decode($payload, $keys); +} catch (LogicException $e) { + // errors having to do with environmental setup or malformed JWT Keys +} catch (UnexpectedValueException $e) { + // errors having to do with JWT signature and claims +} +``` + +#### Casting to array + +The return value of `JWT::decode` is the generic PHP object `stdClass`. If you'd like to handle with arrays +instead, you can do the following: + +```php +// return type is stdClass +$decoded = JWT::decode($payload, $keys); + +// cast to array +$decoded = json_decode(json_encode($decoded), true); +``` + +Tests +----- +Run the tests using phpunit: + +```bash +$ pear install PHPUnit +$ phpunit --configuration phpunit.xml.dist +PHPUnit 3.7.10 by Sebastian Bergmann. +..... +Time: 0 seconds, Memory: 2.50Mb +OK (5 tests, 5 assertions) +``` + +New Lines in private keys +----- + +If your private key contains `\n` characters, be sure to wrap it in double quotes `""` +and not single quotes `''` in order to properly interpret the escaped characters. + +License +------- +[3-Clause BSD](http://opensource.org/licenses/BSD-3-Clause). diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/composer.json b/plugins/hwp-previews/vendor/firebase/php-jwt/composer.json new file mode 100644 index 0000000..816cfd0 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/composer.json @@ -0,0 +1,42 @@ +{ + "name": "firebase/php-jwt", + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "php", + "jwt" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "license": "BSD-3-Clause", + "require": { + "php": "^8.0" + }, + "suggest": { + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "ext-sodium": "Support EdDSA (Ed25519) signatures" + }, + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + } +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/src/BeforeValidException.php b/plugins/hwp-previews/vendor/firebase/php-jwt/src/BeforeValidException.php new file mode 100644 index 0000000..595164b --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/src/BeforeValidException.php @@ -0,0 +1,18 @@ +payload = $payload; + } + + public function getPayload(): object + { + return $this->payload; + } +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/src/CachedKeySet.php b/plugins/hwp-previews/vendor/firebase/php-jwt/src/CachedKeySet.php new file mode 100644 index 0000000..8e8e8d6 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/src/CachedKeySet.php @@ -0,0 +1,274 @@ + + */ +class CachedKeySet implements ArrayAccess +{ + /** + * @var string + */ + private $jwksUri; + /** + * @var ClientInterface + */ + private $httpClient; + /** + * @var RequestFactoryInterface + */ + private $httpFactory; + /** + * @var CacheItemPoolInterface + */ + private $cache; + /** + * @var ?int + */ + private $expiresAfter; + /** + * @var ?CacheItemInterface + */ + private $cacheItem; + /** + * @var array> + */ + private $keySet; + /** + * @var string + */ + private $cacheKey; + /** + * @var string + */ + private $cacheKeyPrefix = 'jwks'; + /** + * @var int + */ + private $maxKeyLength = 64; + /** + * @var bool + */ + private $rateLimit; + /** + * @var string + */ + private $rateLimitCacheKey; + /** + * @var int + */ + private $maxCallsPerMinute = 10; + /** + * @var string|null + */ + private $defaultAlg; + + public function __construct( + string $jwksUri, + ClientInterface $httpClient, + RequestFactoryInterface $httpFactory, + CacheItemPoolInterface $cache, + ?int $expiresAfter = null, + bool $rateLimit = false, + ?string $defaultAlg = null + ) { + $this->jwksUri = $jwksUri; + $this->httpClient = $httpClient; + $this->httpFactory = $httpFactory; + $this->cache = $cache; + $this->expiresAfter = $expiresAfter; + $this->rateLimit = $rateLimit; + $this->defaultAlg = $defaultAlg; + $this->setCacheKeys(); + } + + /** + * @param string $keyId + * @return Key + */ + public function offsetGet($keyId): Key + { + if (!$this->keyIdExists($keyId)) { + throw new OutOfBoundsException('Key ID not found'); + } + return JWK::parseKey($this->keySet[$keyId], $this->defaultAlg); + } + + /** + * @param string $keyId + * @return bool + */ + public function offsetExists($keyId): bool + { + return $this->keyIdExists($keyId); + } + + /** + * @param string $offset + * @param Key $value + */ + public function offsetSet($offset, $value): void + { + throw new LogicException('Method not implemented'); + } + + /** + * @param string $offset + */ + public function offsetUnset($offset): void + { + throw new LogicException('Method not implemented'); + } + + /** + * @return array + */ + private function formatJwksForCache(string $jwks): array + { + $jwks = json_decode($jwks, true); + + if (!isset($jwks['keys'])) { + throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + + if (empty($jwks['keys'])) { + throw new InvalidArgumentException('JWK Set did not contain any keys'); + } + + $keys = []; + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + $keys[(string) $kid] = $v; + } + + return $keys; + } + + private function keyIdExists(string $keyId): bool + { + if (null === $this->keySet) { + $item = $this->getCacheItem(); + // Try to load keys from cache + if ($item->isHit()) { + // item found! retrieve it + $this->keySet = $item->get(); + // If the cached item is a string, the JWKS response was cached (previous behavior). + // Parse this into expected format array instead. + if (\is_string($this->keySet)) { + $this->keySet = $this->formatJwksForCache($this->keySet); + } + } + } + + if (!isset($this->keySet[$keyId])) { + if ($this->rateLimitExceeded()) { + return false; + } + $request = $this->httpFactory->createRequest('GET', $this->jwksUri); + $jwksResponse = $this->httpClient->sendRequest($request); + if ($jwksResponse->getStatusCode() !== 200) { + throw new UnexpectedValueException( + \sprintf('HTTP Error: %d %s for URI "%s"', + $jwksResponse->getStatusCode(), + $jwksResponse->getReasonPhrase(), + $this->jwksUri, + ), + $jwksResponse->getStatusCode() + ); + } + $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); + + if (!isset($this->keySet[$keyId])) { + return false; + } + + $item = $this->getCacheItem(); + $item->set($this->keySet); + if ($this->expiresAfter) { + $item->expiresAfter($this->expiresAfter); + } + $this->cache->save($item); + } + + return true; + } + + private function rateLimitExceeded(): bool + { + if (!$this->rateLimit) { + return false; + } + + $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); + + $cacheItemData = []; + if ($cacheItem->isHit() && \is_array($data = $cacheItem->get())) { + $cacheItemData = $data; + } + + $callsPerMinute = $cacheItemData['callsPerMinute'] ?? 0; + $expiry = $cacheItemData['expiry'] ?? new \DateTime('+60 seconds', new \DateTimeZone('UTC')); + + if (++$callsPerMinute > $this->maxCallsPerMinute) { + return true; + } + + $cacheItem->set(['expiry' => $expiry, 'callsPerMinute' => $callsPerMinute]); + $cacheItem->expiresAt($expiry); + $this->cache->save($cacheItem); + return false; + } + + private function getCacheItem(): CacheItemInterface + { + if (\is_null($this->cacheItem)) { + $this->cacheItem = $this->cache->getItem($this->cacheKey); + } + + return $this->cacheItem; + } + + private function setCacheKeys(): void + { + if (empty($this->jwksUri)) { + throw new RuntimeException('JWKS URI is empty'); + } + + // ensure we do not have illegal characters + $key = preg_replace('|[^a-zA-Z0-9_\.!]|', '', $this->jwksUri); + + // add prefix + $key = $this->cacheKeyPrefix . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($key) > $this->maxKeyLength) { + $key = substr(hash('sha256', $key), 0, $this->maxKeyLength); + } + + $this->cacheKey = $key; + + if ($this->rateLimit) { + // add prefix + $rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key; + + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($rateLimitKey) > $this->maxKeyLength) { + $rateLimitKey = substr(hash('sha256', $rateLimitKey), 0, $this->maxKeyLength); + } + + $this->rateLimitCacheKey = $rateLimitKey; + } + } +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/src/ExpiredException.php b/plugins/hwp-previews/vendor/firebase/php-jwt/src/ExpiredException.php new file mode 100644 index 0000000..12fef09 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/src/ExpiredException.php @@ -0,0 +1,18 @@ +payload = $payload; + } + + public function getPayload(): object + { + return $this->payload; + } +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWK.php b/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWK.php new file mode 100644 index 0000000..405dcc4 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWK.php @@ -0,0 +1,355 @@ + + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWK +{ + private const OID = '1.2.840.10045.2.1'; + private const ASN1_OBJECT_IDENTIFIER = 0x06; + private const ASN1_SEQUENCE = 0x10; // also defined in JWT + private const ASN1_BIT_STRING = 0x03; + private const EC_CURVES = [ + 'P-256' => '1.2.840.10045.3.1.7', // Len: 64 + 'secp256k1' => '1.3.132.0.10', // Len: 64 + 'P-384' => '1.3.132.0.34', // Len: 96 + // 'P-521' => '1.3.132.0.35', // Len: 132 (not supported) + ]; + + // For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype. + // This library supports the following subtypes: + private const OKP_SUBTYPES = [ + 'Ed25519' => true, // RFC 8037 + ]; + + /** + * Parse a set of JWK keys + * + * @param array $jwks The JSON Web Key Set as an associative array + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set + * + * @return array An associative array of key IDs (kid) to Key objects + * + * @throws InvalidArgumentException Provided JWK Set is empty + * @throws UnexpectedValueException Provided JWK Set was invalid + * @throws DomainException OpenSSL failure + * + * @uses parseKey + */ + public static function parseKeySet(array $jwks, ?string $defaultAlg = null): array + { + $keys = []; + + if (!isset($jwks['keys'])) { + throw new UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + + if (empty($jwks['keys'])) { + throw new InvalidArgumentException('JWK Set did not contain any keys'); + } + + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + if ($key = self::parseKey($v, $defaultAlg)) { + $keys[(string) $kid] = $key; + } + } + + if (0 === \count($keys)) { + throw new UnexpectedValueException('No supported algorithms found in JWK Set'); + } + + return $keys; + } + + /** + * Parse a JWK key + * + * @param array $jwk An individual JWK + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set + * + * @return Key The key object for the JWK + * + * @throws InvalidArgumentException Provided JWK is empty + * @throws UnexpectedValueException Provided JWK was invalid + * @throws DomainException OpenSSL failure + * + * @uses createPemFromModulusAndExponent + */ + public static function parseKey(array $jwk, ?string $defaultAlg = null): ?Key + { + if (empty($jwk)) { + throw new InvalidArgumentException('JWK must not be empty'); + } + + if (!isset($jwk['kty'])) { + throw new UnexpectedValueException('JWK must contain a "kty" parameter'); + } + + if (!isset($jwk['alg'])) { + if (\is_null($defaultAlg)) { + // The "alg" parameter is optional in a KTY, but an algorithm is required + // for parsing in this library. Use the $defaultAlg parameter when parsing the + // key set in order to prevent this error. + // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 + throw new UnexpectedValueException('JWK must contain an "alg" parameter'); + } + $jwk['alg'] = $defaultAlg; + } + + switch ($jwk['kty']) { + case 'RSA': + if (!empty($jwk['d'])) { + throw new UnexpectedValueException('RSA private keys are not supported'); + } + if (!isset($jwk['n']) || !isset($jwk['e'])) { + throw new UnexpectedValueException('RSA keys must contain values for both "n" and "e"'); + } + + $pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']); + $publicKey = \openssl_pkey_get_public($pem); + if (false === $publicKey) { + throw new DomainException( + 'OpenSSL error: ' . \openssl_error_string() + ); + } + return new Key($publicKey, $jwk['alg']); + case 'EC': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (empty($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (!isset(self::EC_CURVES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported EC curve'); + } + + if (empty($jwk['x']) || empty($jwk['y'])) { + throw new UnexpectedValueException('x and y not set'); + } + + $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); + return new Key($publicKey, $jwk['alg']); + case 'OKP': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new UnexpectedValueException('Key data must be for a public key'); + } + + if (!isset($jwk['crv'])) { + throw new UnexpectedValueException('crv not set'); + } + + if (empty(self::OKP_SUBTYPES[$jwk['crv']])) { + throw new DomainException('Unrecognised or unsupported OKP key subtype'); + } + + if (empty($jwk['x'])) { + throw new UnexpectedValueException('x not set'); + } + + // This library works internally with EdDSA keys (Ed25519) encoded in standard base64. + $publicKey = JWT::convertBase64urlToBase64($jwk['x']); + return new Key($publicKey, $jwk['alg']); + case 'oct': + if (!isset($jwk['k'])) { + throw new UnexpectedValueException('k not set'); + } + + return new Key(JWT::urlsafeB64Decode($jwk['k']), $jwk['alg']); + default: + break; + } + + return null; + } + + /** + * Converts the EC JWK values to pem format. + * + * @param string $crv The EC curve (only P-256 & P-384 is supported) + * @param string $x The EC x-coordinate + * @param string $y The EC y-coordinate + * + * @return string + */ + private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string + { + $pem = + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::OID) + ) + . self::encodeDER( + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::EC_CURVES[$crv]) + ) + ) . + self::encodeDER( + self::ASN1_BIT_STRING, + \chr(0x00) . \chr(0x04) + . JWT::urlsafeB64Decode($x) + . JWT::urlsafeB64Decode($y) + ) + ); + + return \sprintf( + "-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", + wordwrap(base64_encode($pem), 64, "\n", true) + ); + } + + /** + * Create a public key represented in PEM format from RSA modulus and exponent information + * + * @param string $n The RSA modulus encoded in Base64 + * @param string $e The RSA exponent encoded in Base64 + * + * @return string The RSA public key represented in PEM format + * + * @uses encodeLength + */ + private static function createPemFromModulusAndExponent( + string $n, + string $e + ): string { + $mod = JWT::urlsafeB64Decode($n); + $exp = JWT::urlsafeB64Decode($e); + + $modulus = \pack('Ca*a*', 2, self::encodeLength(\strlen($mod)), $mod); + $publicExponent = \pack('Ca*a*', 2, self::encodeLength(\strlen($exp)), $exp); + + $rsaPublicKey = \pack( + 'Ca*a*a*', + 48, + self::encodeLength(\strlen($modulus) + \strlen($publicExponent)), + $modulus, + $publicExponent + ); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = \pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $rsaPublicKey = \chr(0) . $rsaPublicKey; + $rsaPublicKey = \chr(3) . self::encodeLength(\strlen($rsaPublicKey)) . $rsaPublicKey; + + $rsaPublicKey = \pack( + 'Ca*a*', + 48, + self::encodeLength(\strlen($rsaOID . $rsaPublicKey)), + $rsaOID . $rsaPublicKey + ); + + return "-----BEGIN PUBLIC KEY-----\r\n" . + \chunk_split(\base64_encode($rsaPublicKey), 64) . + '-----END PUBLIC KEY-----'; + } + + /** + * DER-encode the length + * + * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information. + * + * @param int $length + * @return string + */ + private static function encodeLength(int $length): string + { + if ($length <= 0x7F) { + return \chr($length); + } + + $temp = \ltrim(\pack('N', $length), \chr(0)); + + return \pack('Ca*', 0x80 | \strlen($temp), $temp); + } + + /** + * Encodes a value into a DER object. + * Also defined in Firebase\JWT\JWT + * + * @param int $type DER tag + * @param string $value the value to encode + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value): string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + + /** + * Encodes a string into a DER-encoded OID. + * + * @param string $oid the OID string + * @return string the binary DER-encoded OID + */ + private static function encodeOID(string $oid): string + { + $octets = explode('.', $oid); + + // Get the first octet + $first = (int) array_shift($octets); + $second = (int) array_shift($octets); + $oid = \chr($first * 40 + $second); + + // Iterate over subsequent octets + foreach ($octets as $octet) { + if ($octet == 0) { + $oid .= \chr(0x00); + continue; + } + $bin = ''; + + while ($octet) { + $bin .= \chr(0x80 | ($octet & 0x7f)); + $octet >>= 7; + } + $bin[0] = $bin[0] & \chr(0x7f); + + // Convert to big endian if necessary + if (pack('V', 65534) == pack('L', 65534)) { + $oid .= strrev($bin); + } else { + $oid .= $bin; + } + } + + return $oid; + } +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWT.php b/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWT.php new file mode 100644 index 0000000..dd9292a --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWT.php @@ -0,0 +1,667 @@ + + * @author Anant Narayanan + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWT +{ + private const ASN1_INTEGER = 0x02; + private const ASN1_SEQUENCE = 0x10; + private const ASN1_BIT_STRING = 0x03; + + /** + * When checking nbf, iat or expiration times, + * we want to provide some extra leeway time to + * account for clock skew. + * + * @var int + */ + public static $leeway = 0; + + /** + * Allow the current timestamp to be specified. + * Useful for fixing a value within unit testing. + * Will default to PHP time() value if null. + * + * @var ?int + */ + public static $timestamp = null; + + /** + * @var array + */ + public static $supported_algs = [ + 'ES384' => ['openssl', 'SHA384'], + 'ES256' => ['openssl', 'SHA256'], + 'ES256K' => ['openssl', 'SHA256'], + 'HS256' => ['hash_hmac', 'SHA256'], + 'HS384' => ['hash_hmac', 'SHA384'], + 'HS512' => ['hash_hmac', 'SHA512'], + 'RS256' => ['openssl', 'SHA256'], + 'RS384' => ['openssl', 'SHA384'], + 'RS512' => ['openssl', 'SHA512'], + 'EdDSA' => ['sodium_crypto', 'EdDSA'], + ]; + + /** + * Decodes a JWT string into a PHP object. + * + * @param string $jwt The JWT + * @param Key|ArrayAccess|array $keyOrKeyArray The Key or associative array of key IDs + * (kid) to Key objects. + * If the algorithm used is asymmetric, this is + * the public key. + * Each Key object contains an algorithm and + * matching key. + * Supported algorithms are 'ES384','ES256', + * 'HS256', 'HS384', 'HS512', 'RS256', 'RS384' + * and 'RS512'. + * @param stdClass $headers Optional. Populates stdClass with headers. + * + * @return stdClass The JWT's payload as a PHP object + * + * @throws InvalidArgumentException Provided key/key-array was empty or malformed + * @throws DomainException Provided JWT is malformed + * @throws UnexpectedValueException Provided JWT was invalid + * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed + * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' + * @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat' + * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim + * + * @uses jsonDecode + * @uses urlsafeB64Decode + */ + public static function decode( + string $jwt, + $keyOrKeyArray, + ?stdClass &$headers = null + ): stdClass { + // Validate JWT + $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; + + if (empty($keyOrKeyArray)) { + throw new InvalidArgumentException('Key may not be empty'); + } + $tks = \explode('.', $jwt); + if (\count($tks) !== 3) { + throw new UnexpectedValueException('Wrong number of segments'); + } + list($headb64, $bodyb64, $cryptob64) = $tks; + $headerRaw = static::urlsafeB64Decode($headb64); + if (null === ($header = static::jsonDecode($headerRaw))) { + throw new UnexpectedValueException('Invalid header encoding'); + } + if ($headers !== null) { + $headers = $header; + } + $payloadRaw = static::urlsafeB64Decode($bodyb64); + if (null === ($payload = static::jsonDecode($payloadRaw))) { + throw new UnexpectedValueException('Invalid claims encoding'); + } + if (\is_array($payload)) { + // prevent PHP Fatal Error in edge-cases when payload is empty array + $payload = (object) $payload; + } + if (!$payload instanceof stdClass) { + throw new UnexpectedValueException('Payload must be a JSON object'); + } + $sig = static::urlsafeB64Decode($cryptob64); + if (empty($header->alg)) { + throw new UnexpectedValueException('Empty algorithm'); + } + if (empty(static::$supported_algs[$header->alg])) { + throw new UnexpectedValueException('Algorithm not supported'); + } + + $key = self::getKey($keyOrKeyArray, property_exists($header, 'kid') ? $header->kid : null); + + // Check the algorithm + if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { + // See issue #351 + throw new UnexpectedValueException('Incorrect key for this algorithm'); + } + if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], true)) { + // OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures + $sig = self::signatureToDER($sig); + } + if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { + throw new SignatureInvalidException('Signature verification failed'); + } + + // Check the nbf if it is defined. This is the time that the + // token can actually be used. If it's not yet that time, abort. + if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { + $ex = new BeforeValidException( + 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) + ); + $ex->setPayload($payload); + throw $ex; + } + + // Check that this token has been created before 'now'. This prevents + // using tokens that have been created for later use (and haven't + // correctly used the nbf claim). + if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { + $ex = new BeforeValidException( + 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) $payload->iat) + ); + $ex->setPayload($payload); + throw $ex; + } + + // Check if this token has expired. + if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { + $ex = new ExpiredException('Expired token'); + $ex->setPayload($payload); + throw $ex; + } + + return $payload; + } + + /** + * Converts and signs a PHP array into a JWT string. + * + * @param array $payload PHP array + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $keyId + * @param array $head An array with header elements to attach + * + * @return string A signed JWT + * + * @uses jsonEncode + * @uses urlsafeB64Encode + */ + public static function encode( + array $payload, + $key, + string $alg, + ?string $keyId = null, + ?array $head = null + ): string { + $header = ['typ' => 'JWT']; + if (isset($head)) { + $header = \array_merge($header, $head); + } + $header['alg'] = $alg; + if ($keyId !== null) { + $header['kid'] = $keyId; + } + $segments = []; + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header)); + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload)); + $signing_input = \implode('.', $segments); + + $signature = static::sign($signing_input, $key, $alg); + $segments[] = static::urlsafeB64Encode($signature); + + return \implode('.', $segments); + } + + /** + * Sign a string with a given key and algorithm. + * + * @param string $msg The message to sign + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * + * @return string An encrypted message + * + * @throws DomainException Unsupported algorithm or bad key was specified + */ + public static function sign( + string $msg, + $key, + string $alg + ): string { + if (empty(static::$supported_algs[$alg])) { + throw new DomainException('Algorithm not supported'); + } + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'hash_hmac': + if (!\is_string($key)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } + return \hash_hmac($algorithm, $msg, $key, true); + case 'openssl': + $signature = ''; + if (!\is_resource($key) && !openssl_pkey_get_private($key)) { + throw new DomainException('OpenSSL unable to validate key'); + } + $success = \openssl_sign($msg, $signature, $key, $algorithm); // @phpstan-ignore-line + if (!$success) { + throw new DomainException('OpenSSL unable to sign data'); + } + if ($alg === 'ES256' || $alg === 'ES256K') { + $signature = self::signatureFromDER($signature, 256); + } elseif ($alg === 'ES384') { + $signature = self::signatureFromDER($signature, 384); + } + return $signature; + case 'sodium_crypto': + if (!\function_exists('sodium_crypto_sign_detached')) { + throw new DomainException('libsodium is not available'); + } + if (!\is_string($key)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $key)); + $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } + return sodium_crypto_sign_detached($msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } + } + + throw new DomainException('Algorithm not supported'); + } + + /** + * Verify a signature with the message, key and method. Not all methods + * are symmetric, so we must have a separate verify and sign method. + * + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string $alg The algorithm + * + * @return bool + * + * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure + */ + private static function verify( + string $msg, + string $signature, + $keyMaterial, + string $alg + ): bool { + if (empty(static::$supported_algs[$alg])) { + throw new DomainException('Algorithm not supported'); + } + + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'openssl': + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line + if ($success === 1) { + return true; + } + if ($success === 0) { + return false; + } + // returns 1 on success, 0 on failure, -1 on error. + throw new DomainException( + 'OpenSSL error: ' . \openssl_error_string() + ); + case 'sodium_crypto': + if (!\function_exists('sodium_crypto_sign_verify_detached')) { + throw new DomainException('libsodium is not available'); + } + if (!\is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = array_filter(explode("\n", $keyMaterial)); + $key = base64_decode((string) end($lines)); + if (\strlen($key) === 0) { + throw new DomainException('Key cannot be empty string'); + } + if (\strlen($signature) === 0) { + throw new DomainException('Signature cannot be empty string'); + } + return sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (Exception $e) { + throw new DomainException($e->getMessage(), 0, $e); + } + case 'hash_hmac': + default: + if (!\is_string($keyMaterial)) { + throw new InvalidArgumentException('key must be a string when using hmac'); + } + $hash = \hash_hmac($algorithm, $msg, $keyMaterial, true); + return self::constantTimeEquals($hash, $signature); + } + } + + /** + * Decode a JSON string into a PHP object. + * + * @param string $input JSON string + * + * @return mixed The decoded JSON string + * + * @throws DomainException Provided string was invalid JSON + */ + public static function jsonDecode(string $input) + { + $obj = \json_decode($input, false, 512, JSON_BIGINT_AS_STRING); + + if ($errno = \json_last_error()) { + self::handleJsonError($errno); + } elseif ($obj === null && $input !== 'null') { + throw new DomainException('Null result with non-null input'); + } + return $obj; + } + + /** + * Encode a PHP array into a JSON string. + * + * @param array $input A PHP array + * + * @return string JSON representation of the PHP array + * + * @throws DomainException Provided object could not be encoded to valid JSON + */ + public static function jsonEncode(array $input): string + { + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); + if ($errno = \json_last_error()) { + self::handleJsonError($errno); + } elseif ($json === 'null') { + throw new DomainException('Null result with non-null input'); + } + if ($json === false) { + throw new DomainException('Provided object could not be encoded to valid JSON'); + } + return $json; + } + + /** + * Decode a string with URL-safe Base64. + * + * @param string $input A Base64 encoded string + * + * @return string A decoded string + * + * @throws InvalidArgumentException invalid base64 characters + */ + public static function urlsafeB64Decode(string $input): string + { + return \base64_decode(self::convertBase64UrlToBase64($input)); + } + + /** + * Convert a string in the base64url (URL-safe Base64) encoding to standard base64. + * + * @param string $input A Base64 encoded string with URL-safe characters (-_ and no padding) + * + * @return string A Base64 encoded string with standard characters (+/) and padding (=), when + * needed. + * + * @see https://www.rfc-editor.org/rfc/rfc4648 + */ + public static function convertBase64UrlToBase64(string $input): string + { + $remainder = \strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= \str_repeat('=', $padlen); + } + return \strtr($input, '-_', '+/'); + } + + /** + * Encode a string with URL-safe Base64. + * + * @param string $input The string you want encoded + * + * @return string The base64 encode of what you passed in + */ + public static function urlsafeB64Encode(string $input): string + { + return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); + } + + + /** + * Determine if an algorithm has been provided for each Key + * + * @param Key|ArrayAccess|array $keyOrKeyArray + * @param string|null $kid + * + * @throws UnexpectedValueException + * + * @return Key + */ + private static function getKey( + $keyOrKeyArray, + ?string $kid + ): Key { + if ($keyOrKeyArray instanceof Key) { + return $keyOrKeyArray; + } + + if (empty($kid) && $kid !== '0') { + throw new UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + + if ($keyOrKeyArray instanceof CachedKeySet) { + // Skip "isset" check, as this will automatically refresh if not set + return $keyOrKeyArray[$kid]; + } + + if (!isset($keyOrKeyArray[$kid])) { + throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + } + + return $keyOrKeyArray[$kid]; + } + + /** + * @param string $left The string of known length to compare against + * @param string $right The user-supplied string + * @return bool + */ + public static function constantTimeEquals(string $left, string $right): bool + { + if (\function_exists('hash_equals')) { + return \hash_equals($left, $right); + } + $len = \min(self::safeStrlen($left), self::safeStrlen($right)); + + $status = 0; + for ($i = 0; $i < $len; $i++) { + $status |= (\ord($left[$i]) ^ \ord($right[$i])); + } + $status |= (self::safeStrlen($left) ^ self::safeStrlen($right)); + + return ($status === 0); + } + + /** + * Helper method to create a JSON error. + * + * @param int $errno An error number from json_last_error() + * + * @throws DomainException + * + * @return void + */ + private static function handleJsonError(int $errno): void + { + $messages = [ + JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', + JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', + JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', + JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', + JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 + ]; + throw new DomainException( + isset($messages[$errno]) + ? $messages[$errno] + : 'Unknown JSON error: ' . $errno + ); + } + + /** + * Get the number of bytes in cryptographic strings. + * + * @param string $str + * + * @return int + */ + private static function safeStrlen(string $str): int + { + if (\function_exists('mb_strlen')) { + return \mb_strlen($str, '8bit'); + } + return \strlen($str); + } + + /** + * Convert an ECDSA signature to an ASN.1 DER sequence + * + * @param string $sig The ECDSA signature to convert + * @return string The encoded DER object + */ + private static function signatureToDER(string $sig): string + { + // Separate the signature into r-value and s-value + $length = max(1, (int) (\strlen($sig) / 2)); + list($r, $s) = \str_split($sig, $length); + + // Trim leading zeros + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + + // Convert r-value and s-value from unsigned big-endian integers to + // signed two's complement + if (\ord($r[0]) > 0x7f) { + $r = "\x00" . $r; + } + if (\ord($s[0]) > 0x7f) { + $s = "\x00" . $s; + } + + return self::encodeDER( + self::ASN1_SEQUENCE, + self::encodeDER(self::ASN1_INTEGER, $r) . + self::encodeDER(self::ASN1_INTEGER, $s) + ); + } + + /** + * Encodes a value into a DER object. + * + * @param int $type DER tag + * @param string $value the value to encode + * + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value): string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + + /** + * Encodes signature from a DER object. + * + * @param string $der binary signature in DER format + * @param int $keySize the number of bits in the key + * + * @return string the signature + */ + private static function signatureFromDER(string $der, int $keySize): string + { + // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE + list($offset, $_) = self::readDER($der); + list($offset, $r) = self::readDER($der, $offset); + list($offset, $s) = self::readDER($der, $offset); + + // Convert r-value and s-value from signed two's compliment to unsigned + // big-endian integers + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + + // Pad out r and s so that they are $keySize bits long + $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT); + $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT); + + return $r . $s; + } + + /** + * Reads binary DER-encoded data and decodes into a single object + * + * @param string $der the binary data in DER format + * @param int $offset the offset of the data stream containing the object + * to decode + * + * @return array{int, string|null} the new offset and the decoded object + */ + private static function readDER(string $der, int $offset = 0): array + { + $pos = $offset; + $size = \strlen($der); + $constructed = (\ord($der[$pos]) >> 5) & 0x01; + $type = \ord($der[$pos++]) & 0x1f; + + // Length + $len = \ord($der[$pos++]); + if ($len & 0x80) { + $n = $len & 0x1f; + $len = 0; + while ($n-- && $pos < $size) { + $len = ($len << 8) | \ord($der[$pos++]); + } + } + + // Value + if ($type === self::ASN1_BIT_STRING) { + $pos++; // Skip the first contents octet (padding indicator) + $data = \substr($der, $pos, $len - 1); + $pos += $len - 1; + } elseif (!$constructed) { + $data = \substr($der, $pos, $len); + $pos += $len; + } else { + $data = null; + } + + return [$pos, $data]; + } +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php b/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php new file mode 100644 index 0000000..7933ed6 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php @@ -0,0 +1,20 @@ +algorithm; + } + + /** + * @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate + */ + public function getKeyMaterial() + { + return $this->keyMaterial; + } +} diff --git a/plugins/hwp-previews/vendor/firebase/php-jwt/src/SignatureInvalidException.php b/plugins/hwp-previews/vendor/firebase/php-jwt/src/SignatureInvalidException.php new file mode 100644 index 0000000..d35dee9 --- /dev/null +++ b/plugins/hwp-previews/vendor/firebase/php-jwt/src/SignatureInvalidException.php @@ -0,0 +1,7 @@ + Date: Mon, 10 Mar 2025 18:03:49 +0100 Subject: [PATCH 2/3] settings logic and description changes --- plugins/hwp-previews/src/Plugin.php | 34 +++++++++++++----- .../Settings/Headless_Preview_Settings.php | 36 ++----------------- .../src/Token/REST/Token_REST_Controller.php | 8 ++--- .../hwp-previews/templates/hwp-preview.php | 2 ++ 4 files changed, 34 insertions(+), 46 deletions(-) diff --git a/plugins/hwp-previews/src/Plugin.php b/plugins/hwp-previews/src/Plugin.php index 28a9bfe..b589b06 100644 --- a/plugins/hwp-previews/src/Plugin.php +++ b/plugins/hwp-previews/src/Plugin.php @@ -136,7 +136,8 @@ private function register_hooks(): void { $this->enable_post_statuses_as_parent(); } - if ( $this->settings->get_setting( Headless_Preview_Settings::GENERATE_PREVIEW_LINKS, false ) ) { + // The preview url generation works only when the iframe preview disabled. + if ( ! $this->settings->get_setting( Headless_Preview_Settings::PREVIEW_IN_IFRAME, true ) ) { $this->enable_generate_preview_url(); } @@ -267,17 +268,32 @@ private function generate_preview_url( WP_Post $post ): string { return ''; // todo: maybe do something? } - $token_auth = $this->settings->get_setting( Headless_Preview_Settings::TOKEN_AUTH_ENABLED, true ); - $draft_route = $this->settings->get_setting( Headless_Preview_Settings::DRAFT_ROUTE, '' ); + $args = (array) apply_filters( + 'hwp_preview_args', + $this->parameter_builder->build_preview_args( $post, $this->generate_token() ), + $post + ); - $token = ''; - if ( $this->settings->get_setting( Headless_Preview_Settings::GENERATE_PREVIEW_TOKEN, true ) ) { - $token = $this->token_generator->generate_token( $token_auth ? [ 'data' => [ 'user' => [ 'id' => get_current_user_id() ] ] ] : [], 'preview_url_nonce', 300 ); - } + return $this->url_generator->generate_url( + $post, + $preview_url, + $args, + $this->settings->get_setting( Headless_Preview_Settings::DRAFT_ROUTE, '' ) + ); + } - $args = (array) apply_filters( 'hwp_preview_args', $this->parameter_builder->build_preview_args( $post, $token ), $post ); + private function generate_token(): string { + if (! $this->settings->get_setting( Headless_Preview_Settings::GENERATE_PREVIEW_TOKEN, true )) { + return ''; + } - return $this->url_generator->generate_url( $post, $preview_url, $args, $draft_route ); + return $this->token_generator->generate_token( + $this->settings->get_setting( Headless_Preview_Settings::TOKEN_AUTH_ENABLED, true ) ? + [ 'data' => [ 'user' => [ 'id' => get_current_user_id() ] ] ] : + [], + 'preview_url_nonce', + 300 + ); } public function enable_rest_route_token_verification(): void { diff --git a/plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php b/plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php index 8e02ad9..ba0f4d1 100644 --- a/plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php +++ b/plugins/hwp-previews/src/Settings/Headless_Preview_Settings.php @@ -12,7 +12,6 @@ class Headless_Preview_Settings { */ const OPTION_NAME = 'headless_preview_settings'; - public const GENERATE_PREVIEW_LINKS = 'generate_preview_links'; public const ENABLE_UNIQUE_POST_SLUG = 'enable_unique_post_slug'; public const ENABLE_POST_STATUSES_AS_PARENT = 'enable_post_statuses_as_parent'; public const PREVIEW_URL = 'preview_url'; @@ -30,7 +29,6 @@ class Headless_Preview_Settings { * Default settings */ private $defaults = [ - self::GENERATE_PREVIEW_LINKS => false, self::PREVIEW_URL => 'http://localhost:3000', self::ENABLE_UNIQUE_POST_SLUG => true, self::GENERATE_PREVIEW_TOKEN => true, @@ -107,10 +105,9 @@ public function register_settings() { 'headless-preview' ); - // Authentication Settings Section add_settings_section( 'auth_settings', - 'Authentication Settings', + 'Security Settings', [ $this, 'render_auth_section' ], 'headless-preview' ); @@ -156,15 +153,6 @@ public function register_settings() { 'general_settings' ); - // Preview Settings Fields - add_settings_field( - 'generate_preview_links', - 'Generate Preview Links', - [ $this, 'render_generate_preview_links_field' ], - 'headless-preview', - 'preview_settings' - ); - add_settings_field( 'preview_in_iframe', 'Preview in iFrame', @@ -292,24 +280,6 @@ public function render_rest_section() { echo '

Settings for REST API integration for token verification.

'; } - /** - * Render the Generate Preview Links field - */ - public function render_generate_preview_links_field() { - $value = isset( $this->settings['generate_preview_links'] ) ? $this->settings['generate_preview_links'] : false; - ?> - -

- When enabled, WordPress's default preview links will be replaced with links to your headless frontend. - Should be disabled by default for compatibility with standard WordPress workflows. -

- When enabled the Preview URL filtering for the WP builtin buttons won't work.

[preview_parameter_names][token]" value=""/> - Authentication token parameter + Security token parameter Post Slug @@ -657,7 +628,6 @@ public function sanitize_settings( $input ) { // Boolean fields $boolean_fields = [ - 'generate_preview_links', 'generate_preview_token', 'token_auth_enabled', 'preview_in_iframe', diff --git a/plugins/hwp-previews/src/Token/REST/Token_REST_Controller.php b/plugins/hwp-previews/src/Token/REST/Token_REST_Controller.php index da1f67e..2ba110f 100644 --- a/plugins/hwp-previews/src/Token/REST/Token_REST_Controller.php +++ b/plugins/hwp-previews/src/Token/REST/Token_REST_Controller.php @@ -50,7 +50,7 @@ public function route_callback( WP_REST_Request $request ): WP_REST_Response { return new WP_REST_Response( [ 'error' => 'Body does not have valid parameters' ], 400 ); } - $token = (string) json_decode( (string) $request->get_param( self::TOKEN_PARAMETER ) ); + $token = (string) $request->get_param( self::TOKEN_PARAMETER ); if ( ! $token ) { return new WP_REST_Response( [ 'error' => 'A token is required' ], 400 ); @@ -58,9 +58,9 @@ public function route_callback( WP_REST_Request $request ): WP_REST_Response { $success = $this->verifier->verify_token( $token, $this->nonce_action ); -// if ( ! $success ) { -// return new WP_REST_Response( [ 'valid' => false, 'error' => 'The token is not valid.' ], 500 ); -// } + if ( ! $success ) { + return new WP_REST_Response( [ 'valid' => false, 'error' => 'The token is not valid.' ], 500 ); + } return new WP_REST_Response( [ 'valid' => true, 'data' => [ 'success' => true ] ], 200 ); } diff --git a/plugins/hwp-previews/templates/hwp-preview.php b/plugins/hwp-previews/templates/hwp-preview.php index 9adefb9..2d54228 100644 --- a/plugins/hwp-previews/templates/hwp-preview.php +++ b/plugins/hwp-previews/templates/hwp-preview.php @@ -11,6 +11,8 @@ $preview_url = (string) get_query_var( Preview_Template_Resolver::HWP_PREVIEWS_IFRAME_PREVIEW_URL ); +var_dump($preview_url); + do_action( 'hwp_previews_before_get_header' ); get_header( $header_name, apply_filters( 'hwp_previews_header_args', [] ) ); From b51db9f9a1fb0e4ed76ba86e4a02728adca3f509 Mon Sep 17 00:00:00 2001 From: Alex Kazhukhouski Date: Mon, 24 Mar 2025 14:52:37 +0100 Subject: [PATCH 3/3] Adds reflection class and preview URL parameter mechanisms --- .../src/Preview/Link/Preview_Link_Service.php | 57 +++++++++++++++ .../Parameters/Abstract_Preview_Parameter.php | 28 ++++++++ .../Parameters/Callback_Preview_Parameter.php | 31 ++++++++ .../Preview_Parameter_Builder_Interface.php | 13 ++++ .../Contracts/Preview_Parameter_Interface.php | 17 +++++ .../Parameters/Preview_Parameter_Builder.php | 71 +++++++++++++++++++ .../Parameters/Preview_Parameter_Factory.php | 39 ++++++++++ .../Parameters/Preview_Parameter_Registry.php | 66 +++++++++++++++++ .../Parameters/WP_Post_Property_Parameter.php | 30 ++++++++ .../Preview_URL_Generator_Interface.php | 2 +- .../src/Preview/URL/Preview_URL_Generator.php | 3 + .../Reflection/Class_Property_Extractor.php | 64 +++++++++++++++++ .../Class_Property_Extractor_Interface.php | 11 +++ .../Contracts/Doc_Block_Parser_Interface.php | 11 +++ .../Contracts/Property_Info_Interface.php | 15 ++++ .../Property_Type_Resolver_Interface.php | 11 +++ .../src/Reflection/Doc_Block_Parser.php | 36 ++++++++++ .../src/Reflection/Property_Info.php | 32 +++++++++ .../src/Reflection/Property_Type_Resolver.php | 43 +++++++++++ 19 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 plugins/hwp-previews/src/Preview/Link/Preview_Link_Service.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Abstract_Preview_Parameter.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Callback_Preview_Parameter.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Contracts/Preview_Parameter_Builder_Interface.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Contracts/Preview_Parameter_Interface.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Builder.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Factory.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Registry.php create mode 100644 plugins/hwp-previews/src/Preview/Parameters/WP_Post_Property_Parameter.php create mode 100644 plugins/hwp-previews/src/Reflection/Class_Property_Extractor.php create mode 100644 plugins/hwp-previews/src/Reflection/Contracts/Class_Property_Extractor_Interface.php create mode 100644 plugins/hwp-previews/src/Reflection/Contracts/Doc_Block_Parser_Interface.php create mode 100644 plugins/hwp-previews/src/Reflection/Contracts/Property_Info_Interface.php create mode 100644 plugins/hwp-previews/src/Reflection/Contracts/Property_Type_Resolver_Interface.php create mode 100644 plugins/hwp-previews/src/Reflection/Doc_Block_Parser.php create mode 100644 plugins/hwp-previews/src/Reflection/Property_Info.php create mode 100644 plugins/hwp-previews/src/Reflection/Property_Type_Resolver.php diff --git a/plugins/hwp-previews/src/Preview/Link/Preview_Link_Service.php b/plugins/hwp-previews/src/Preview/Link/Preview_Link_Service.php new file mode 100644 index 0000000..9aa5f64 --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Link/Preview_Link_Service.php @@ -0,0 +1,57 @@ +types = $types; + $this->statuses = $statuses; + $this->registry = $registry; + } + + public function generate_preview_post_link( string $preview_url, WP_Post $post, string $route = '' ): string { + if ( + ! $preview_url || + ! $this->types->is_post_type_applicable( $post->post_type ) || + ! $this->statuses->is_post_status_applicable( $post->post_status ) + ) { + return ''; + } + + $parameters = []; + + foreach ( $this->registry->get_all() as $parameter ) { + $value = $parameter->get_value( $post ); + if ( $value ) { + $parameters[ $parameter->get_name() ] = urlencode( $value ); + } + } + + if ( empty( $parameters ) ) { + return $preview_url; + } + + if ( $route ) { + $preview_url = trailingslashit( $preview_url ) . $route; + } + + return add_query_arg( $parameters, $preview_url ); + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/Parameters/Abstract_Preview_Parameter.php b/plugins/hwp-previews/src/Preview/Parameters/Abstract_Preview_Parameter.php new file mode 100644 index 0000000..fa318cb --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Parameters/Abstract_Preview_Parameter.php @@ -0,0 +1,28 @@ +name = $name; + $this->description = $description; + } + + public function get_name(): string { + return $this->name; + } + + public function get_description(): string { + return $this->description; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/Parameters/Callback_Preview_Parameter.php b/plugins/hwp-previews/src/Preview/Parameters/Callback_Preview_Parameter.php new file mode 100644 index 0000000..7f220a0 --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Parameters/Callback_Preview_Parameter.php @@ -0,0 +1,31 @@ +callback = $callback; + } + + public function get_value( WP_Post $post ): string { + $value = call_user_func( $this->callback, $post ); + + if ( ! is_string( $value ) ) { + throw new InvalidArgumentException( 'Callback must return a string.' ); + } + + return $value; + } +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/Parameters/Contracts/Preview_Parameter_Builder_Interface.php b/plugins/hwp-previews/src/Preview/Parameters/Contracts/Preview_Parameter_Builder_Interface.php new file mode 100644 index 0000000..8e45f41 --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Parameters/Contracts/Preview_Parameter_Builder_Interface.php @@ -0,0 +1,13 @@ +parameter_names = $parameter_names; + } + + /** + * @param WP_Post $post + * @param string $token + * + * @return array + */ + public function build_preview_args( WP_Post $post, string $page_uri, string $token ): array { + // Add default preview param. + $args = [ + $this->parameter_names->preview => 'true', + ]; + + // Add post slug param. + if ( $this->parameter_names->post_slug ) { + $args[ $this->parameter_names->post_slug ] = $post->post_name; + } + + // Add post id param. + if ( $this->parameter_names->post_id ) { + $args[ $this->parameter_names->post_id ] = $post->ID; + } + + // Add post type param. + if ( $this->parameter_names->post_type ) { + $args[ $this->parameter_names->post_type ] = $post->post_type; + } + + // Add graphql single name param. + if ( $this->parameter_names->graphql_single ) { + $post_type_object = get_post_type_object( $post->post_type ); + + if ( ! empty( $post_type_object->graphql_single_name ) ) { + $args[ $this->parameter_names->graphql_single ] = ucfirst( $post_type_object->graphql_single_name ); + } + } + + // Add page uri param. + if ( $this->parameter_names->post_uri ) { + $page_uri = (string) get_page_uri( $post->ID ); + + if ($page_uri) { + $args[ $this->parameter_names->post_uri ] = $page_uri; + } + } + + // Add token param. + if ( $this->parameter_names->token && $token ) { + $args[ $this->parameter_names->token ] = $token; + } + + return $args; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Factory.php b/plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Factory.php new file mode 100644 index 0000000..49a4faf --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Parameters/Preview_Parameter_Factory.php @@ -0,0 +1,39 @@ +parameters[ $parameter->get_name() ] = $parameter; + + return $this; + } + + /** + * Unregister a parameter. + * + * @param string $name The parameter name. + * + * @return self + */ + public function unregister( string $name ): self { + if ( isset( $this->parameters[ $name ] ) ) { + unset( $this->parameters[ $name ] ); + } + + return $this; + } + + /** + * Get all registered parameters. + * + * @return Preview_Parameter_Interface[] + */ + public function get_all(): array { + return $this->parameters; + } + + /** + * Get a specific parameter by name. + * + * @param string $name The parameter name. + * + * @return Preview_Parameter_Interface|null + */ + public function get( string $name ): ?Preview_Parameter_Interface { + return $this->parameters[ $name ] ?? null; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/Parameters/WP_Post_Property_Parameter.php b/plugins/hwp-previews/src/Preview/Parameters/WP_Post_Property_Parameter.php new file mode 100644 index 0000000..bb82c8a --- /dev/null +++ b/plugins/hwp-previews/src/Preview/Parameters/WP_Post_Property_Parameter.php @@ -0,0 +1,30 @@ +property = $property; + } + + public function get_value( WP_Post $post ): string { + if ( empty( $post->{$this->property} ) ) { + return ''; + } + + if ( is_array( $post->{$this->property} ) || is_object( $post->{$this->property} ) ) { + return (string) wp_json_encode( $post->{$this->property} ); + } + + return (string) $post->{$this->property}; + } + +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/URL/Contracts/Preview_URL_Generator_Interface.php b/plugins/hwp-previews/src/Preview/URL/Contracts/Preview_URL_Generator_Interface.php index 2ab2558..5f90455 100644 --- a/plugins/hwp-previews/src/Preview/URL/Contracts/Preview_URL_Generator_Interface.php +++ b/plugins/hwp-previews/src/Preview/URL/Contracts/Preview_URL_Generator_Interface.php @@ -8,6 +8,6 @@ interface Preview_URL_Generator_Interface { - public function generate_url( WP_Post $post, string $frontend_url, array $args, string $draft_route = '' ): string; + public function generate_url( WP_Post $post, string $frontend_url, string $page_uri, array $args, string $draft_route = '' ): string; } \ No newline at end of file diff --git a/plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php b/plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php index 3fb5b28..d382aa0 100644 --- a/plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php +++ b/plugins/hwp-previews/src/Preview/URL/Preview_URL_Generator.php @@ -22,6 +22,7 @@ public function __construct( Post_Types_Config_Interface $types, Post_Statuses_C public function generate_url( WP_Post $post, string $frontend_url, + string $page_uri, array $args, string $draft_route = '' ): string { @@ -36,6 +37,8 @@ public function generate_url( // Format frontend URL to the draft route handler. if ( $draft_route ) { $frontend_url = trailingslashit( $frontend_url ) . $draft_route; + } else { + $frontend_url = trailingslashit( $frontend_url ) . $page_uri; } if ( $args ) { diff --git a/plugins/hwp-previews/src/Reflection/Class_Property_Extractor.php b/plugins/hwp-previews/src/Reflection/Class_Property_Extractor.php new file mode 100644 index 0000000..d3c8e38 --- /dev/null +++ b/plugins/hwp-previews/src/Reflection/Class_Property_Extractor.php @@ -0,0 +1,64 @@ +type_resolver = $type_resolver; + $this->doc_block_parser = $doc_block_parser; + } + + /** + * @param class-string $class_name + * + * @return Property_Info[] + */ + public function extract_public_properties( string $class_name ): array { + try { + $reflection = new ReflectionClass( $class_name ); + } catch ( Exception $e ) { + error_log( $e->getMessage() ); + + return []; + } + + $properties = $reflection->getProperties( ReflectionProperty::IS_PUBLIC ); + $public_properties = []; + + foreach ( $properties as $property ) { + if ( $property->isStatic() ) { + continue; + } + + $type = $this->type_resolver->resolve_type( $property ); + $description = $this->doc_block_parser->parse_description( $property->getDocComment() ); + + $property_info = new Property_Info( + $property->getName(), + $type, + $description + ); + + $public_properties[ $property->getName() ] = $property_info; + } + + return $public_properties; + } +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Reflection/Contracts/Class_Property_Extractor_Interface.php b/plugins/hwp-previews/src/Reflection/Contracts/Class_Property_Extractor_Interface.php new file mode 100644 index 0000000..84a624b --- /dev/null +++ b/plugins/hwp-previews/src/Reflection/Contracts/Class_Property_Extractor_Interface.php @@ -0,0 +1,11 @@ +name = $name; + $this->type = $type; + $this->description = $description; + } + + public function get_name(): string { + return $this->name; + } + + public function get_type(): string { + return $this->type; + } + + public function get_description(): string { + return $this->description; + } +} \ No newline at end of file diff --git a/plugins/hwp-previews/src/Reflection/Property_Type_Resolver.php b/plugins/hwp-previews/src/Reflection/Property_Type_Resolver.php new file mode 100644 index 0000000..46907b3 --- /dev/null +++ b/plugins/hwp-previews/src/Reflection/Property_Type_Resolver.php @@ -0,0 +1,43 @@ +resolve_native_type( $property ); + if ( $nativeType ) { + return $nativeType; + } + + return $this->resolve_doc_block_type( $property ); + } + + public function resolve_native_type( ReflectionProperty $property ): string { + if ( $property->hasType() ) { + $type = $property->getType(); + + return $type->getName(); + } + + return ''; + } + + private function resolve_doc_block_type( ReflectionProperty $property ): string { + $docComment = $property->getDocComment(); + if ( ! $docComment ) { + return ''; + } + + if ( preg_match( '/@var\s+([^\s]+)/', $docComment, $matches ) ) { + return $matches[1]; + } + + return ''; + } +} \ No newline at end of file