oont-contents/plugins/wp-rocket/inc/Engine/Optimization/RUCSS/Controller/UsedCSS.php
2025-02-08 15:10:23 +01:00

1002 lines
24 KiB
PHP

<?php
declare( strict_types=1 );
namespace WP_Rocket\Engine\Optimization\RUCSS\Controller;
use WP_Rocket\Admin\Options_Data;
use WP_Rocket\Engine\Common\Queue\QueueInterface;
use WP_Rocket\Engine\Optimization\CSSTrait;
use WP_Rocket\Engine\Optimization\DynamicLists\DataManager;
use WP_Rocket\Engine\Optimization\RegexTrait;
use WP_Rocket\Engine\Optimization\RUCSS\Database\Queries\UsedCSS as UsedCSS_Query;
use WP_Rocket\Engine\Optimization\RUCSS\Frontend\APIClient;
use WP_Rocket\Logger\Logger;
use WP_Admin_Bar;
class UsedCSS {
use RegexTrait, CSSTrait;
/**
* UsedCss Query instance.
*
* @var UsedCSS_Query
*/
private $used_css_query;
/**
* Plugin options instance.
*
* @var Options_Data
*/
protected $options;
/**
* APIClient instance
*
* @var APIClient
*/
private $api;
/**
* Queue instance.
*
* @var QueueInterface
*/
private $queue;
/**
* DataManager instance
*
* @var DataManager
*/
private $data_manager;
/**
* Filesystem instance
*
* @var Filesystem
*/
private $filesystem;
/**
* External exclusions list, can be urls or attributes.
*
* @var array
*/
private $external_exclusions = [];
/**
* Inline CSS attributes exclusions patterns to be preserved on the page after treeshaking.
*
* @var string[]
*/
private $inline_atts_exclusions = [];
/**
* Inline CSS content exclusions patterns to be preserved on the page after treeshaking.
*
* @var string[]
*/
private $inline_content_exclusions = [];
/**
* Instantiate the class.
*
* @param Options_Data $options Options instance.
* @param UsedCSS_Query $used_css_query Usedcss Query instance.
* @param APIClient $api APIClient instance.
* @param QueueInterface $queue Queue instance.
* @param DataManager $data_manager DataManager instance.
* @param Filesystem $filesystem Filesystem instance.
*/
public function __construct(
Options_Data $options,
UsedCSS_Query $used_css_query,
APIClient $api,
QueueInterface $queue,
DataManager $data_manager,
Filesystem $filesystem
) {
$this->options = $options;
$this->used_css_query = $used_css_query;
$this->api = $api;
$this->queue = $queue;
$this->data_manager = $data_manager;
$this->filesystem = $filesystem;
}
/**
* Determines if we treeshake the CSS.
*
* @return boolean
*/
public function is_allowed(): bool {
if ( rocket_get_constant( 'DONOTROCKETOPTIMIZE' ) ) {
return false;
}
if ( rocket_bypass() ) {
return false;
}
if ( ! $this->is_enabled() ) {
return false;
}
if ( $this->is_password_protected() ) {
return false;
}
if ( is_rocket_post_excluded_option( 'remove_unused_css' ) ) {
return false;
}
// Bailout if user is logged in.
if ( is_user_logged_in() ) {
return false;
}
if ( ! $this->filesystem->is_writable_folder() ) {
return false;
}
return true;
}
/**
* Check if RUCSS option is enabled.
*
* Used inside the CRON so post object isn't there.
*
* @return bool
*/
public function is_enabled() {
return (bool) $this->options->get( 'remove_unused_css', 0 );
}
/**
* Can optimize url.
*
* @return bool
*/
private function can_optimize_url() {
if ( rocket_bypass() ) {
return false;
}
if ( ! $this->is_enabled() ) {
return false;
}
return ! is_rocket_post_excluded_option( 'remove_unused_css' );
}
/**
* Checks if on a single post and if it is password protected
*
* @since 3.11
*
* @return bool
*/
private function is_password_protected(): bool {
if ( ! is_singular() ) {
return false;
}
$post = get_post();
return ! empty( $post->post_password );
}
/**
* Start treeshaking the current page.
*
* @param string $html Buffet HTML for current page.
*
* @return string
*/
public function treeshake( string $html ): string {
if ( ! $this->is_allowed() ) {
return $html;
}
global $wp;
$url = untrailingslashit( home_url( add_query_arg( [], $wp->request ) ) );
$is_mobile = $this->is_mobile();
$used_css = $this->used_css_query->get_row( $url, $is_mobile );
if ( empty( $used_css ) ) {
$add_to_queue_response = $this->add_url_to_the_queue( $url, $is_mobile );
if ( false === $add_to_queue_response ) {
return $html;
}
/**
* Lock preload URL.
*
* @param string $url URL to lock
*/
do_action( 'rocket_preload_lock_url', $url );
// We got jobid and queue name so save them into the DB and change status to be pending.
$this->used_css_query->create_new_job(
$url,
$add_to_queue_response['contents']['jobId'],
$add_to_queue_response['contents']['queueName'],
$is_mobile
);
return $html;
}
if ( 'completed' !== $used_css->status || empty( $used_css->hash ) ) {
return $html;
}
$used_css_content = $this->filesystem->get_used_css( $used_css->hash );
if ( empty( $used_css_content ) ) {
$this->used_css_query->delete_by_url( $url );
return $html;
}
$html = $this->remove_used_css_from_html( $html );
$html = $this->add_used_css_to_html( $html, $used_css_content );
$html = $this->add_used_fonts_preload( $html, $used_css_content );
$html = $this->remove_google_font_preconnect( $html );
$this->used_css_query->update_last_accessed( (int) $used_css->id );
return $html;
}
/**
* Send the request to add url into the queue.
*
* @param string $url page URL.
* @param bool $is_mobile page is for mobile.
*
* @return array|bool An array of response data, or false.
*/
public function add_url_to_the_queue( string $url, bool $is_mobile ) {
/**
* Filters the RUCSS safelist
*
* @since 3.11
*
* @param array $safelist Array of safelist values.
*/
$safelist = apply_filters( 'rocket_rucss_safelist', $this->options->get( 'remove_unused_css_safelist', [] ) );
$config = [
'treeshake' => 1,
'rucss_safelist' => $safelist,
'is_mobile' => $is_mobile,
'is_home' => $this->is_home( $url ),
];
$add_to_queue_response = $this->api->add_to_queue( $url, $config );
if ( 200 !== $add_to_queue_response['code'] ) {
Logger::error(
'Error when contacting the RUCSS API.',
[
'rucss error',
'url' => $url,
'code' => $add_to_queue_response['code'],
'message' => $add_to_queue_response['message'],
]
);
return false;
}
return $add_to_queue_response;
}
/**
* Delete used css based on URL.
*
* @param string $url The page URL.
*
* @return boolean
*/
public function delete_used_css( string $url ): bool {
$used_css_arr = $this->used_css_query->get_rows_by_url( $url );
if ( empty( $used_css_arr ) ) {
return false;
}
$deleted = true;
foreach ( $used_css_arr as $used_css ) {
if ( empty( $used_css->id ) ) {
continue;
}
$deleted = $deleted && $this->used_css_query->delete_item( $used_css->id );
$count = $this->used_css_query->count_rows_by_hash( $used_css->hash );
if ( 0 === $count ) {
$this->filesystem->delete_used_css( $used_css->hash );
}
}
return $deleted;
}
/**
* Deletes all the used CSS files
*
* @since 3.11.4
*
* @return void
*/
public function delete_all_used_css() {
$this->filesystem->delete_all_used_css();
}
/**
* Alter HTML and remove all CSS which was processed from HTML page.
*
* @param string $html HTML content.
*
* @return string HTML content.
*/
private function remove_used_css_from_html( string $html ): string {
$clean_html = $this->hide_comments( $html );
$clean_html = $this->hide_noscripts( $clean_html );
$clean_html = $this->hide_scripts( $clean_html );
$this->set_inline_exclusions_lists();
$html = $this->remove_external_styles_from_html( $clean_html, $html );
return $this->remove_internal_styles_from_html( $clean_html, $html );
}
/**
* Remove external styles from the page's HTML.
*
* @param string $clean_html Cleaned HTML after removing comments, noscripts and scripts.
* @param string $html Actual page's HTML.
*
* @return string
*/
private function remove_external_styles_from_html( string $clean_html, string $html ) {
$link_styles = $this->find(
'<link\s+([^>]+[\s"\'])?href\s*=\s*[\'"]\s*?(?<url>[^\'"]+(?:\?[^\'"]*)?)\s*?[\'"]([^>]+)?\/?>',
$clean_html,
'Uis'
);
$preserve_google_font = apply_filters( 'rocket_rucss_preserve_google_font', false );
$external_exclusions = $this->validate_array_and_quote(
/**
* Filters the array of external exclusions.
*
* @since 3.11.4
*
* @param array $external_exclusions Array of patterns used to match against the external style tag.
*/
(array) apply_filters( 'rocket_rucss_external_exclusions', $this->external_exclusions )
);
foreach ( $link_styles as $style ) {
if (
! (bool) preg_match( '/rel=[\'"]?stylesheet[\'"]?/is', $style[0] )
&&
! ( (bool) preg_match( '/rel=[\'"]?preload[\'"]?/is', $style[0] ) && (bool) preg_match( '/as=[\'"]?style[\'"]?/is', $style[0] ) )
||
( $preserve_google_font && strstr( $style['url'], '//fonts.googleapis.com/css' ) )
) {
continue;
}
if ( ! empty( $external_exclusions ) && $this->find( implode( '|', $external_exclusions ), $style[0] ) ) {
continue;
}
$html = str_replace( $style[0], '', $html );
}
return (string) $html;
}
/**
* Remove internal styles from the page's HTML.
*
* @param string $clean_html Cleaned HTML after removing comments, noscripts and scripts.
* @param string $html Actual page's HTML.
*
* @return string
*/
private function remove_internal_styles_from_html( string $clean_html, string $html ) {
$inline_styles = $this->find(
'<style(?<atts>.*)>(?<content>.*)<\/style\s*>',
$clean_html
);
$inline_atts_exclusions = $this->validate_array_and_quote(
/**
* Filters the array of inline CSS attributes patterns to preserve
*
* @since 3.11
*
* @param array $inline_atts_exclusions Array of patterns used to match against the inline CSS attributes.
*/
(array) apply_filters( 'rocket_rucss_inline_atts_exclusions', $this->inline_atts_exclusions )
);
$inline_content_exclusions = $this->validate_array_and_quote(
/**
* Filters the array of inline CSS content patterns to preserve
*
* @since 3.11
*
* @param array $inline_atts_exclusions Array of patterns used to match against the inline CSS content.
*/
(array) apply_filters( 'rocket_rucss_inline_content_exclusions', $this->inline_content_exclusions )
);
foreach ( $inline_styles as $style ) {
if ( ! empty( $inline_atts_exclusions ) && $this->find( implode( '|', $inline_atts_exclusions ), $style['atts'] ) ) {
continue;
}
if ( ! empty( $inline_content_exclusions ) && $this->find( implode( '|', $inline_content_exclusions ), $style['content'] ) ) {
continue;
}
/**
* Filters the status of preserving inline style tags.
*
* @since 3.11.4
*
* @param bool $preserve_status Status of preserve.
* @param array $style Full match style tag.
*/
if ( apply_filters( 'rocket_rucss_preserve_inline_style_tags', true, $style ) ) {
$content = trim( $style['content'] );
if ( empty( $content ) ) {
continue;
}
$empty_tag = str_replace( $style['content'], '', $style[0] );
$html = str_replace( $style[0], $empty_tag, $html );
continue;
}
$html = str_replace( $style[0], '', $html );
}
return $html;
}
/**
* Alter HTML string and add the used CSS style in <head> tag,
*
* @param string $html HTML content.
* @param string $used_css Used CSS content.
*
* @return string HTML content.
*/
private function add_used_css_to_html( string $html, string $used_css ): string {
$replace = preg_replace(
'#</title>#iU',
'</title>' . $this->get_used_css_markup( $used_css ),
$html,
1
);
if ( null === $replace ) {
return $html;
}
return $replace;
}
/**
* Return Markup for used_css into the page.
*
* @param string $used_css Used CSS content.
*
* @return string
*/
private function get_used_css_markup( string $used_css ): string {
/**
* Filters Used CSS content before output.
*
* @since 3.9.0.2
*
* @param string $used_css Used CSS content.
*/
$used_css = apply_filters( 'rocket_usedcss_content', $used_css );
$used_css = str_replace( '\\', '\\\\', $used_css );// Guard the backslashes before passing the content to preg_replace.
$used_css = $this->handle_charsets( $used_css, false );
return sprintf(
'<style id="wpr-usedcss">%s</style>',
$used_css
);
}
/**
* Determines if the page is mobile and separate cache for mobile files is enabled.
*
* @return boolean
*/
private function is_mobile(): bool {
return $this->options->get( 'cache_mobile', 0 )
&& $this->options->get( 'do_caching_mobile_files', 0 )
&& wp_is_mobile();
}
/**
* Check if current page is the home page.
*
* @param string $url Current page url.
*
* @return bool
*/
private function is_home( string $url ): bool {
/**
* Filters the home url.
*
* @since 3.11.4
*
* @param string $home_url home url.
* @param string $url url of current page.
*/
$home_url = apply_filters( 'rocket_rucss_is_home_url', home_url(), $url );
return untrailingslashit( $url ) === untrailingslashit( $home_url );
}
/**
* Process pending jobs inside cron iteration.
*
* @return void
*/
public function process_pending_jobs() {
Logger::debug( 'RUCSS: Start processing pending jobs inside cron.' );
if ( ! $this->is_enabled() ) {
Logger::debug( 'RUCSS: Stop processing cron iteration because option is disabled.' );
return;
}
// Get some items from the DB with status=pending & job_id isn't empty.
/**
* Filters the pending jobs count.
*
* @since 3.11
*
* @param int $rows Number of rows to grab with each CRON iteration.
*/
$rows = apply_filters( 'rocket_rucss_pending_jobs_cron_rows_count', 100 );
Logger::debug( "RUCSS: Start getting number of {$rows} pending jobs." );
$pending_jobs = $this->used_css_query->get_pending_jobs( $rows );
if ( ! $pending_jobs ) {
Logger::debug( 'RUCSS: No pending jobs are there.' );
return;
}
foreach ( $pending_jobs as $used_css_row ) {
Logger::debug( "RUCSS: Send the job for url {$used_css_row->url} to Async task to check its job status." );
// Change status to in-progress.
$this->used_css_query->make_status_inprogress( (int) $used_css_row->id );
$this->queue->add_job_status_check_async( (int) $used_css_row->id );
}
}
/**
* Check job status by DB row ID.
*
* @param int $id DB Row ID.
*
* @return void
*/
public function check_job_status( int $id ) {
Logger::debug( 'RUCSS: Start checking job status for row ID: ' . $id );
$new_job_id = false;
$row_details = $this->used_css_query->get_item( $id );
if ( ! $row_details ) {
Logger::debug( 'RUCSS: Row ID not found ', compact( 'id' ) );
// Nothing in DB, bailout.
return;
}
// Send the request to get the job status from SaaS.
$job_details = $this->api->get_queue_job_status( $row_details->job_id, $row_details->queue_name, $this->is_home( $row_details->url ) );
if (
200 !== $job_details['code']
||
empty( $job_details['contents'] )
||
! isset( $job_details['contents']['shakedCSS'] )
) {
Logger::debug( 'RUCSS: Job status failed for url: ' . $row_details->url, $job_details );
// Failure, check the retries number.
if ( $row_details->retries >= 3 ) {
Logger::debug( 'RUCSS: Job failed 3 times for url: ' . $row_details->url );
/**
* Unlock preload URL.
*
* @param string $url URL to unlock
*/
do_action( 'rocket_preload_unlock_url', $row_details->url );
$this->used_css_query->make_status_failed( $id, strval( $job_details['code'] ), $job_details['message'] );
return;
}
// on timeout errors with code 408 create new job.
switch ( $job_details['code'] ) {
case 408:
$add_to_queue_response = $this->add_url_to_the_queue( $row_details->url, (bool) $row_details->is_mobile );
if ( false !== $add_to_queue_response ) {
$new_job_id = $add_to_queue_response['contents']['jobId'];
$this->used_css_query->update_job_id( $id, $new_job_id );
}
break;
}
// Increment the retries number with 1 , Change status to pending again and change job id on timeout.
$this->used_css_query->increment_retries( $id, $row_details->retries );
// @Todo: Maybe we can add this row to the async job to get the status before the next cron
return;
}
/**
* Unlock preload URL.
*
* @param string $url URL to unlock
*/
do_action( 'rocket_preload_unlock_url', $row_details->url );
$css = $this->apply_font_display_swap( $job_details['contents']['shakedCSS'] );
$hash = md5( $css );
if ( ! $this->filesystem->write_used_css( $hash, $css ) ) {
$message = 'RUCSS: Could not write used CSS to the filesystem: ' . $row_details->url;
Logger::error( $message );
$this->used_css_query->make_status_failed( $id, '', $message );
return;
}
// Everything is fine, save the usedcss into DB, change status to completed and reset queue_name and job_id.
Logger::debug( 'RUCSS: Save used CSS for url: ' . $row_details->url );
$this->used_css_query->make_status_completed( $id, $hash );
/**
* Fires after successfully saving the used CSS for an URL
*
* @param string $url URL used to generated the used CSS.
* @param array $job_details Result of the request to get the job status from SaaS.
*/
do_action( 'rocket_rucss_complete_job_status', $row_details->url, $job_details );
}
/**
* Add clear UsedCSS adminbar item.
*
* @param WP_Admin_Bar $wp_admin_bar Adminbar object.
*
* @return void
*/
public function add_clear_usedcss_bar_item( WP_Admin_Bar $wp_admin_bar ) {
global $post;
if ( 'local' === wp_get_environment_type() ) {
return;
}
if ( ! current_user_can( 'rocket_remove_unused_css' ) ) {
return;
}
if ( is_admin() ) {
return;
}
if ( ! $this->can_optimize_url() ) {
return;
}
if ( ! rocket_can_display_options() ) {
return;
}
/**
* Filters the rocket `clear used css of this url` option on admin bar menu.
*
* @since 3.12.1
*
* @param bool $should_skip Should skip adding `clear used css of this url` option in admin bar.
* @param type $post Post object.
*/
if ( apply_filters( 'rocket_skip_admin_bar_clear_used_css_option', false, $post ) ) {
return;
}
$referer = '';
$action = 'rocket_clear_usedcss_url';
if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
$referer_url = filter_var( wp_unslash( $_SERVER['REQUEST_URI'] ), FILTER_SANITIZE_URL );
$referer = '&_wp_http_referer=' . rawurlencode( remove_query_arg( 'fl_builder', $referer_url ) );
}
/**
* Clear usedCSS for this URL (frontend).
*/
$wp_admin_bar->add_menu(
[
'parent' => 'wp-rocket',
'id' => 'clear-usedcss-url',
'title' => __( 'Clear Used CSS of this URL', 'rocket' ),
'href' => wp_nonce_url( admin_url( 'admin-post.php?action=' . $action . $referer ), $action ),
]
);
}
/**
* Clear specific url.
*
* @param string $url Page url.
*
* @return void
*/
public function clear_url_usedcss( string $url ) {
$this->delete_used_css( $url );
/**
* Fires after clearing usedcss for specific url.
*
* @since 3.11
*
* @param string $url Current page URL.
*/
do_action( 'rocket_rucss_after_clearing_usedcss', $url );
}
/**
* Get the count of not completed rows.
*
* @return int
*/
public function get_not_completed_count() {
return $this->used_css_query->get_not_completed_count();
}
/**
* Add preload links for the fonts in the used CSS
*
* @param string $html HTML content.
* @param string $used_css Used CSS content.
*
* @return string
*/
private function add_used_fonts_preload( string $html, string $used_css ): string {
/**
* Filters the fonts preload from the used CSS
*
* @since 3.11
*
* @param bool $enable True to enable, false to disable.
*/
if ( ! apply_filters( 'rocket_enable_rucss_fonts_preload', true ) ) {
return $html;
}
if ( ! preg_match_all( '/@font-face\s*{\s*(?<content>[^}]+)}/is', $used_css, $font_faces, PREG_SET_ORDER ) ) {
return $html;
}
if ( empty( $font_faces ) ) {
return $html;
}
$urls = [];
foreach ( $font_faces as $font_face ) {
if ( empty( $font_face['content'] ) ) {
continue;
}
$font_url = $this->extract_first_font( $font_face['content'] );
/**
* Filters font URL with CDN hostname
*
* @since 3.11.4
*
* @param type $url url to be rewritten.
*/
$font_url = apply_filters( 'rocket_font_url', $font_url );
if ( empty( $font_url ) ) {
continue;
}
$urls[] = $font_url;
}
if ( empty( $urls ) ) {
return $html;
}
$urls = array_unique( $urls );
$replace = preg_replace(
'#</title>#iU',
'</title>' . $this->preload_links( $urls ),
$html,
1
);
if ( null === $replace ) {
return $html;
}
return $replace;
}
/**
* Remove preconnect tag for google api.
*
* @param string $html html content.
*
* @return string
*/
protected function remove_google_font_preconnect( string $html ): string {
$clean_html = $this->hide_comments( $html );
$clean_html = $this->hide_noscripts( $clean_html );
$clean_html = $this->hide_scripts( $clean_html );
$links = $this->find(
'<link\s+([^>]+[\s"\'])?rel\s*=\s*[\'"]((preconnect)|(dns-prefetch))[\'"]([^>]+)?\/?>',
$clean_html,
'Uis'
);
foreach ( $links as $link ) {
if ( preg_match( '/href=[\'"](https:)?\/\/fonts.googleapis.com\/?[\'"]/', $link[0] ) ) {
$html = str_replace( $link[0], '', $html );
}
}
return $html;
}
/**
* Extracts the first font URL from the font-face declaration
*
* Skips .eot fonts if it exists
*
* @since 3.11
*
* @param string $font_face Font-face declaration content.
*
* @return string
*/
private function extract_first_font( string $font_face ): string {
if ( ! preg_match_all( '/src:\s*(?<urls>[^;}]*)/is', $font_face, $sources, PREG_SET_ORDER ) ) {
return '';
}
foreach ( $sources as $src ) {
if ( empty( $src['urls'] ) ) {
continue;
}
$urls = explode( ',', $src['urls'] );
foreach ( $urls as $url ) {
if ( false !== strpos( $url, '.eot' ) ) {
continue;
}
if ( ! preg_match( '/url\(\s*[\'"]?(?<url>[^\'")]+)[\'"]?\)/is', $url, $matches ) ) {
continue;
}
return trim( $matches['url'] );
}
}
return '';
}
/**
* Converts an array of URLs to preload link tags
*
* @param array $urls An array of URLs.
*
* @return string
*/
private function preload_links( array $urls ): string {
$links = '';
foreach ( $urls as $url ) {
$links .= '<link rel="preload" as="font" href="' . esc_url( $url ) . '" crossorigin>';
}
return $links;
}
/**
* Set Rucss inline attr exclusions
*
* @return void
*/
private function set_inline_exclusions_lists() {
$wpr_dynamic_lists = $this->data_manager->get_lists();
$this->inline_atts_exclusions = isset( $wpr_dynamic_lists->rucss_inline_atts_exclusions ) ? $wpr_dynamic_lists->rucss_inline_atts_exclusions : [];
$this->inline_content_exclusions = isset( $wpr_dynamic_lists->rucss_inline_content_exclusions ) ? $wpr_dynamic_lists->rucss_inline_content_exclusions : [];
}
/**
* Displays a notice if the used CSS folder is not writable
*
* @since 3.11.4
*
* @return void
*/
public function notice_write_permissions() {
if ( ! current_user_can( 'rocket_manage_options' ) ) {
return;
}
if ( ! $this->is_enabled() ) {
return;
}
if ( $this->filesystem->is_writable_folder() ) {
return;
}
$message = rocket_notice_writing_permissions( trim( str_replace( rocket_get_constant( 'ABSPATH', '' ), '', rocket_get_constant( 'WP_ROCKET_USED_CSS_PATH', '' ) ), '/' ) );
rocket_notice_html(
[
'status' => 'error',
'dismissible' => '',
'message' => $message,
]
);
}
/**
* Validate the items in array to be strings only and preg_quote them.
*
* @param array $items Array to be validated and quoted.
*
* @return array|string[]
*/
private function validate_array_and_quote( array $items ) {
$items_array = array_filter( $items, 'is_string' );
return array_map(
static function ( $item ) {
return preg_quote( $item, '/' );
},
$items_array
);
}
}