Merged in feature/from-pantheon (pull request #16)

code from pantheon

* code from pantheon
This commit is contained in:
Tony Volpe
2024-01-10 17:03:02 +00:00
parent 054b4fffc9
commit 4eb982d7a8
16492 changed files with 3475854 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
<?php
namespace Yoast\WP\SEO\Premium\Helpers;
use RuntimeException;
use WP_User;
use WPSEO_Utils;
use Yoast\WP\SEO\Helpers\Options_Helper;
use Yoast\WP\SEO\Helpers\User_Helper;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Bad_Request_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Forbidden_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Internal_Server_Error_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Not_Found_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Payment_Required_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Request_Timeout_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Service_Unavailable_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Too_Many_Requests_Exception;
use Yoast\WP\SEO\Premium\Exceptions\Remote_Request\Unauthorized_Exception;
/**
* Class AI_Generator_Helper
*
* @package Yoast\WP\SEO\Helpers
*/
class AI_Generator_Helper {
/**
* The API base URL.
*
* @var string
*/
protected $base_url = 'https://ai.yoa.st/api/v1';
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* The User helper.
*
* @var User_Helper
*/
protected $user_helper;
/**
* AI_Generator_Helper constructor.
*
* @codeCoverageIgnore It only sets dependencies.
*
* @param Options_Helper $options The options helper.
* @param User_Helper $user_helper The User helper.
*/
public function __construct( Options_Helper $options, User_Helper $user_helper ) {
$this->options_helper = $options;
$this->user_helper = $user_helper;
}
/**
* Generates a random code verifier for a user. The code verifier is used in communication with the Yoast AI API
* to ensure that the callback that is sent for both the token and refresh request are handled by the same site that requested the tokens.
* Each code verifier should only be used once.
* This all helps with preventing access tokens from one site to be sent to another and it makes a mitm attack more difficult to execute.
*
* @param \WP_User $user The WP user.
*
* @return string The code verifier.
*/
public function generate_code_verifier( WP_User $user ) {
$random_string = \substr( \str_shuffle( '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' ), 1, 10 );
return \hash( 'sha256', $user->user_email . $random_string );
}
/**
* Temporarily stores the code verifier. We expect the callback that consumes this verifier to reach us within a couple of seconds.
* So, we throw away the code after 5 minutes: when we know the callback isn't coming.
*
* @param int $user_id The user ID.
* @param string $code_verifier The code verifier.
*
* @return void
*/
public function set_code_verifier( int $user_id, string $code_verifier ): void {
$user_id_string = (string) $user_id;
\set_transient( "yoast_wpseo_ai_generator_code_verifier_$user_id_string", $code_verifier, ( \MINUTE_IN_SECONDS * 5 ) );
}
/**
* Retrieves the code verifier.
*
* @param int $user_id The user ID.
*
* @throws \RuntimeException Unable to retrieve the code verifier.
*
* @return string The code verifier.
*/
public function get_code_verifier( int $user_id ): string {
$user_id_string = (string) $user_id;
$code_verifier = \get_transient( "yoast_wpseo_ai_generator_code_verifier_$user_id_string" );
if ( ! \is_string( $code_verifier ) || $code_verifier === '' ) {
throw new RuntimeException( 'Unable to retrieve the code verifier.' );
}
return $code_verifier;
}
/**
* Deletes the code verifier.
*
* @param int $user_id The user ID.
*
* @return void
*/
public function delete_code_verifier( int $user_id ): void {
$user_id_string = (string) $user_id;
\delete_transient( "yoast_wpseo_ai_generator_code_verifier_$user_id_string" );
}
/**
* Gets the licence URL.
*
* @return string The licence URL.
*/
public function get_license_url() {
return WPSEO_Utils::get_home_url();
}
/**
* Gets the callback URL to be used by the API to send back the access token, refresh token and code challenge.
*
* @return array The callbacks URLs.
*/
public function get_callback_url() {
return \get_rest_url( null, 'yoast/v1/ai_generator/callback' );
}
/**
* Gets the callback URL to be used by the API to send back the refreshed JWTs once they expire.
*
* @return array The callbacks URLs.
*/
public function get_refresh_callback_url() {
return \get_rest_url( null, 'yoast/v1/ai_generator/refresh_callback' );
}
/**
* Performs the request using WordPress internals.
*
* @param string $action_path The path to the desired action.
* @param array $request_body The request body.
* @param array $request_headers The request headers.
*
* @throws Bad_Request_Exception When the request fails for any other reason.
* @throws Forbidden_Exception When the response code is 403.
* @throws Internal_Server_Error_Exception When the response code is 500.
* @throws Not_Found_Exception When the response code is 404.
* @throws Payment_Required_Exception When the response code is 402.
* @throws Request_Timeout_Exception When the response code is 408.
* @throws Service_Unavailable_Exception When the response code is 503.
* @throws Too_Many_Requests_Exception When the response code is 429.
* @throws Unauthorized_Exception When the response code is 401.
*
* @return object The response object.
*/
public function request( $action_path, $request_body = [], $request_headers = [] ) {
// Our API expects JSON.
// The request times out after 30 seconds.
$request_headers = \array_merge( $request_headers, [ 'Content-Type' => 'application/json' ] );
$request_arguments = [
'timeout' => 30,
// phpcs:ignore Yoast.Yoast.AlternativeFunctions.json_encode_wp_json_encode -- Reason: We don't want the debug/pretty possibility.
'body' => \wp_json_encode( $request_body ),
'headers' => $request_headers,
];
/**
* Filter: 'Yoast\WP\SEO\ai_api_url' - Replaces the default URL for the AI API with a custom one.
*
* Note: This is a Premium plugin-only hook.
*
* @since 21.0
* @internal
*
* @param string $url The default URL for the AI API.
*/
$api_url = \apply_filters( 'Yoast\WP\SEO\ai_api_url', $this->base_url );
$response = \wp_remote_post( $api_url . $action_path, $request_arguments );
if ( \is_wp_error( $response ) ) {
throw new Bad_Request_Exception( $response->get_error_message(), $response->get_error_code() );
}
[ $response_code, $response_message ] = $this->parse_response( $response );
switch ( $response_code ) {
case 200:
return (object) $response;
case 401:
throw new Unauthorized_Exception( $response_message, $response_code );
case 402:
throw new Payment_Required_Exception( $response_message, $response_code );
case 403:
throw new Forbidden_Exception( $response_message, $response_code );
case 404:
throw new Not_Found_Exception( $response_message, $response_code );
case 408:
throw new Request_Timeout_Exception( $response_message, $response_code );
case 429:
throw new Too_Many_Requests_Exception( $response_message, $response_code );
case 500:
throw new Internal_Server_Error_Exception( $response_message, $response_code );
case 503:
throw new Service_Unavailable_Exception( $response_message, $response_code );
default:
throw new Bad_Request_Exception( $response_message, $response_code );
}
}
/**
* Generates the list of 5 suggestions to return.
*
* @param object $response The response from the API.
*
* @return array The array of suggestions.
*/
public function build_suggestions_array( $response ): array {
$suggestions = [];
$json = \json_decode( $response->body );
if ( $json === null || ! isset( $json->choices ) ) {
return $suggestions;
}
foreach ( $json->choices as $suggestion ) {
$suggestions[] = $suggestion->text;
}
return $suggestions;
}
/**
* Parses the response from the API.
*
* @param array|\WP_Error $response The response from the API.
*
* @return array The response code and message.
*/
public function parse_response( $response ) {
$response_code = ( \wp_remote_retrieve_response_code( $response ) !== '' ) ? \wp_remote_retrieve_response_code( $response ) : 0;
$response_message = \esc_html( \wp_remote_retrieve_response_message( $response ) );
if ( $response_code !== 200 && $response_code !== 0 ) {
$json_body = \json_decode( \wp_remote_retrieve_body( $response ) );
if ( $json_body !== null ) {
$response_message = isset( $json_body->error_code ) ? $json_body->error_code : $this->map_message_to_code( $json_body->message );
}
}
return [ $response_code, $response_message ];
}
/**
* Checks whether the token has expired.
*
* @param string $jwt The JWT.
*
* @return bool Whether the token has expired.
*/
public function has_token_expired( string $jwt ): bool {
$parts = \explode( '.', $jwt );
if ( \count( $parts ) !== 3 ) {
// Headers, payload and signature parts are not detected.
return true;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reason: Decoding the payload of the JWT.
$payload = \base64_decode( $parts[1] );
$json = \json_decode( $payload );
if ( $json === null || ! isset( $json->exp ) ) {
return true;
}
return $json->exp < time();
}
/**
* Retrieves the access JWT.
*
* @param string $user_id The user ID.
*
* @throws \RuntimeException Unable to retrieve the access token.
*
* @return string The access JWT.
*/
public function get_access_token( string $user_id ): string {
$access_jwt = $this->user_helper->get_meta( $user_id, '_yoast_wpseo_ai_generator_access_jwt', true );
if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
throw new RuntimeException( 'Unable to retrieve the access token.' );
}
return $access_jwt;
}
/**
* Retrieves the refresh JWT.
*
* @param string $user_id The user ID.
*
* @throws \RuntimeException Unable to retrieve the refresh token.
*
* @return string The access JWT.
*/
public function get_refresh_token( $user_id ) {
$refresh_jwt = $this->user_helper->get_meta( $user_id, '_yoast_wpseo_ai_generator_refresh_jwt', true );
if ( ! \is_string( $refresh_jwt ) || $refresh_jwt === '' ) {
throw new RuntimeException( 'Unable to retrieve the refresh token.' );
}
return $refresh_jwt;
}
/**
* Checks if the AI Generator feature is active.
*
* @return bool Whether the feature is active.
*/
public function is_ai_generator_enabled() {
return $this->options_helper->get( 'enable_ai_generator', false );
}
/**
* Maps the message to a code.
*
* @param string $message The message.
*
* @return string The code.
*/
private function map_message_to_code( $message ) {
if ( \strpos( $message, 'must NOT have fewer than 1 characters' ) !== false ) {
return 'NOT_ENOUGH_CONTENT';
}
if ( \strpos( $message, 'Client timeout' ) !== false ) {
return 'CLIENT_TIMEOUT';
}
if ( \strpos( $message, 'Server timeout' ) !== false ) {
return 'SERVER_TIMEOUT';
}
return $message;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Yoast\WP\SEO\Premium\Helpers;
/**
* Class Current_Page_Helper.
*/
class Current_Page_Helper {
/**
* Determine whether the current page is the homepage and shows posts.
*
* @return bool
*/
public function is_home_posts_page() {
return ( \is_home() && \get_option( 'show_on_front' ) !== 'page' );
}
/**
* Determine whether the current page is a static homepage.
*
* @return bool
*/
public function is_home_static_page() {
return ( \is_front_page() && \get_option( 'show_on_front' ) === 'page' && \is_page( \get_option( 'page_on_front' ) ) );
}
/**
* Determine whether this is the posts page, regardless of whether it's the frontpage or not.
*
* @return bool
*/
public function is_posts_page() {
return ( \is_home() && ! \is_front_page() );
}
/**
* Retrieves the current post id.
* Returns 0 if no post id is found.
*
* @return int The post id.
*/
public function get_current_post_id() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are not processing form information, We are casting to an integer.
if ( isset( $_GET['post'] ) && \is_string( $_GET['post'] ) && (int) \wp_unslash( $_GET['post'] ) > 0 ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reason: We are not processing form information, We are casting to an integer, also this is a helper function.
return (int) \wp_unslash( $_GET['post'] );
}
return 0;
}
/**
* Retrieves the current post type.
*
* @return string The post type.
*/
public function get_current_post_type() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
if ( isset( $_GET['post_type'] ) && \is_string( $_GET['post_type'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
return \sanitize_text_field( \wp_unslash( $_GET['post_type'] ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reason: should be done outside the helper function.
if ( isset( $_POST['post_type'] ) && \is_string( $_POST['post_type'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reason: should be done outside the helper function.
return \sanitize_text_field( \wp_unslash( $_POST['post_type'] ) );
}
$post_id = $this->get_current_post_id();
if ( $post_id ) {
return \get_post_type( $post_id );
}
return 'post';
}
/**
* Retrieves the current taxonomy.
*
* @return string The taxonomy.
*/
public function get_current_taxonomy() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- doing a strict in_array check should be sufficient.
if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || ! \in_array( $_SERVER['REQUEST_METHOD'], [ 'GET', 'POST' ], true ) ) {
return '';
}
// phpcs:ignore WordPress.Security.NonceVerification -- Reason: We are not processing form information.
if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reason: should be done outside the helper function.
if ( isset( $_POST['taxonomy'] ) && \is_string( $_POST['taxonomy'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Reason: should be done outside the helper function.
return \sanitize_text_field( \wp_unslash( $_POST['taxonomy'] ) );
}
return '';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
if ( isset( $_GET['taxonomy'] ) && \is_string( $_GET['taxonomy'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information.
return \sanitize_text_field( \wp_unslash( $_GET['taxonomy'] ) );
}
return '';
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Yoast\WP\SEO\Premium\Helpers;
use Yoast\WP\SEO\Helpers\Options_Helper;
/**
* Class Prominent_Words_Helper.
*/
class Prominent_Words_Helper {
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options_helper;
/**
* Prominent_Words_Helper constructor.
*
* @param Options_Helper $options_helper The options helper.
*/
public function __construct( Options_Helper $options_helper ) {
$this->options_helper = $options_helper;
}
/**
* Computes the tf-idf (term frequency - inverse document frequency) score of a prominent word in a document.
* The document frequency should be 1 or higher, if it is not, it is assumed to be 1.
*
* @param int $term_frequency How many times the word occurs in the document.
* @param int $doc_frequency In how many documents this word occurs.
*
* @return float The tf-idf score of a prominent word.
*/
public function compute_tf_idf_score( $term_frequency, $doc_frequency ) {
// Set doc frequency to a minimum of 1, to avoid division by 0.
$doc_frequency = \max( 1, $doc_frequency );
return ( $term_frequency * ( 1 / $doc_frequency ) );
}
/**
* Computes the vector length for the given prominent words, applying Pythagoras's Theorem on the weights.
*
* @param array $prominent_words The prominent words, as an array mapping stems to `weight` and `df` (document frequency).
*
* @return float Vector length for the prominent words.
*/
public function compute_vector_length( $prominent_words ) {
$sum_of_squares = 0;
foreach ( $prominent_words as $stem => $word ) {
$doc_frequency = 1;
if ( \array_key_exists( 'df', $word ) ) {
$doc_frequency = $word['df'];
}
$tf_idf = $this->compute_tf_idf_score( $word['weight'], $doc_frequency );
$sum_of_squares += ( $tf_idf ** 2 );
}
return \sqrt( $sum_of_squares );
}
/**
* Completes the prominent words indexing.
*/
public function complete_indexing() {
$this->set_indexing_completed( true );
\set_transient( 'total_unindexed_prominent_words', '0' );
}
/**
* Sets the prominent_words_indexing_completed option.
*
* @param bool $indexing_completed Whether or not the prominent words indexing has completed.
*/
public function set_indexing_completed( $indexing_completed ) {
$this->options_helper->set( 'prominent_words_indexing_completed', $indexing_completed );
}
/**
* Gets a boolean that indicates whether the prominent words indexing has completed.
*
* @return bool Whether the prominent words indexing has completed.
*/
public function is_indexing_completed() {
return $this->options_helper->get( 'prominent_words_indexing_completed' );
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Yoast\WP\SEO\Premium\Helpers;
use Yoast\WP\SEO\Premium\Addon_Installer;
/**
* Helper class to check the status of the Free and Premium versions.
*/
class Version_Helper {
/**
* Checks whether Free is active and set to a version later than the minimum required.
*
* @return bool
*/
public function is_free_upgraded() {
return ( \defined( 'WPSEO_VERSION' ) && \version_compare( \WPSEO_VERSION, Addon_Installer::MINIMUM_YOAST_SEO_VERSION . '-RC0', '>' ) );
}
/**
* Checks whether a new update is available for Premium.
*
* @return bool
*/
public function is_premium_update_available() {
$plugin_updates = \get_plugin_updates();
return isset( $plugin_updates[ WPSEO_PREMIUM_BASENAME ] );
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace Yoast\WP\SEO\Helpers;
use WPSEO_Utils;
use Yoast\WP\SEO\Models\Indexable;
use Yoast\WP\SEO\Surfaces\Meta_Surface;
/**
* Class Zapier_Helper
*
* @package Yoast\WP\SEO\Helpers
*/
class Zapier_Helper {
/**
* The options helper.
*
* @var Options_Helper
*/
protected $options;
/**
* The meta surface.
*
* @var Meta_Surface
*/
protected $meta_surface;
/**
* Zapier_Helper constructor.
*
* @codeCoverageIgnore It only sets dependencies.
*
* @param Options_Helper $options The options helper.
* @param Meta_Surface $meta_surface The Meta surface.
*/
public function __construct( Options_Helper $options, Meta_Surface $meta_surface ) {
$this->options = $options;
$this->meta_surface = $meta_surface;
}
/**
* Checks if a subscription exists in the database.
*
* @return bool Whether a subscription exists in the database.
*/
public function is_connected() {
$subscription = $this->options->get( 'zapier_subscription' );
if ( \is_array( $subscription )
&& ! empty( $subscription['id'] )
&& \filter_var( $subscription['url'], \FILTER_VALIDATE_URL )
) {
return true;
}
return false;
}
/**
* Checks if the Zapier integration is currently enabled.
*
* @return bool Whether the integration is enabled.
*/
public function is_enabled() {
return (bool) $this->options->get( 'zapier_integration_active', false );
}
/**
* Gets the stored Zapier API Key.
*
* @return string The Zapier API Key.
*/
public function get_or_generate_zapier_api_key() {
$zapier_api_key = $this->options->get( 'zapier_api_key' );
if ( empty( $zapier_api_key ) ) {
$zapier_api_key = \wp_generate_password( 32, false );
$this->options->set( 'zapier_api_key', $zapier_api_key );
}
return $zapier_api_key;
}
/**
* Check if a string matches the API key in the DB, if present.
*
* @param string $api_key The API key to test.
*
* @return bool Whether the API key is valid or not.
*/
public function is_valid_api_key( $api_key ) {
return ( ! empty( $api_key ) && $this->options->get( 'zapier_api_key' ) === $api_key );
}
/**
* Returns the Zapier hook URL of the trigger if present, null otherwise.
*
* @return string|null The hook URL, null if not set.
*/
public function get_trigger_url() {
if ( $this->is_connected() ) {
$subscription = $this->options->get( 'zapier_subscription', [] );
return $subscription['url'];
}
return null;
}
/**
* Returns whether the submitted id is present in the subscriptions.
*
* @param string $id The id to be tested.
*
* @return bool Whether the id is present in the subscriptions.
*/
public function is_subscribed_id( $id ) {
if ( $this->is_connected() ) {
$subscription = $this->options->get( 'zapier_subscription', [] );
return $subscription['id'] === $id;
}
return false;
}
/**
* Unsubscribes the submitted id.
*
* @param string $id The id to be unsubscribed.
*
* @return bool Whether the unsubscription was successful.
*/
public function unsubscribe_id( $id ) {
if ( $this->is_connected() && $this->is_subscribed_id( $id ) ) {
return $this->options->set( 'zapier_subscription', [] );
}
return false;
}
/**
* Creates a new subscription with the submitted URL.
*
* @param string $url The URL to be subscribed.
*
* @return array|bool The subscription data (id and URL) if successful, false otherwise.
*/
public function subscribe_url( $url ) {
if ( ! $this->is_connected() ) {
$subscription_data = [
'id' => \wp_generate_password( 32, false ),
'url' => \esc_url_raw( $url, [ 'http', 'https' ] ),
];
if ( $this->options->set( 'zapier_subscription', $subscription_data ) ) {
return $subscription_data;
}
}
return false;
}
/**
* Builds and returns the data for Zapier.
*
* @param Indexable $indexable The indexable from which the data must be extracted.
*
* @return array[] The array of data ready to be sent to Zapier.
*/
public function get_data_for_zapier( Indexable $indexable ) {
$post = \get_post( $indexable->object_id );
if ( ! $post ) {
return [];
}
$meta = $this->meta_surface->for_indexable( $indexable );
$open_graph_image = '';
if ( \count( $meta->open_graph_images ) > 0 ) {
$open_graph_image_array = \reset( $meta->open_graph_images );
$open_graph_image = $open_graph_image_array['url'];
}
return [
'url' => $indexable->permalink,
'post_type' => $post->post_type,
'post_title' => \html_entity_decode( $post->post_title ),
'author' => \get_the_author_meta( 'display_name', $post->post_author ),
'tags' => \html_entity_decode( \implode( ', ', \wp_get_post_tags( $post->ID, [ 'fields' => 'names' ] ) ) ),
'categories' => \html_entity_decode( \implode( ', ', \wp_get_post_categories( $post->ID, [ 'fields' => 'names' ] ) ) ),
'primary_category' => \html_entity_decode( \yoast_get_primary_term( 'category', $post ) ),
'meta_description' => \html_entity_decode( $meta->description ),
'open_graph_title' => \html_entity_decode( $meta->open_graph_title ),
'open_graph_description' => \html_entity_decode( $meta->open_graph_description ),
'open_graph_image' => $open_graph_image,
'twitter_title' => \html_entity_decode( $meta->twitter_title ),
'twitter_description' => \html_entity_decode( $meta->twitter_description ),
'twitter_image' => $meta->twitter_image,
];
}
/**
* Returns whether the post type is supported by the Zapier integration.
*
* The Zapier integration should be visible and working only for post types
* that support the Yoast Metabox. We filter out attachments regardless of
* the Yoast SEO settings, anyway.
*
* @param string $post_type The post type to be checked.
*
* @return bool Whether the post type is supported by the Zapier integration.
*/
public function is_post_type_supported( $post_type ) {
return $post_type !== 'attachment' && WPSEO_Utils::is_metabox_active( $post_type, 'post_type' );
}
}