Files
2026-04-28 15:13:50 +02:00

759 lines
21 KiB
PHP

<?php
/**
* Utility functions collection.
*
* Provides shared helper methods and reusable logic used across
* different components of the plugin.
*
* @package Head_Footer_Code
* @since 1.4.0
*/
namespace Techwebux\Hfc;
// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Common {
/** @var array Settings retrieved from the main controller. */
private static $settings = null;
/** @var Plugin_Info Plugin metadata object. */
protected static $plugin;
/** @var array Custom set of allowed HTML tags. */
private static $allowed_html = null;
/**
* Injects the plugin metadata object into the Common utility class.
*
* This allows static methods to access plugin properties like name,
* version, and directory without needing global constants.
*
* @param Plugin_Info $plugin The plugin metadata container.
* @param array $settings Optional settings array to initialize.
*/
public static function init( Plugin_Info $plugin, $settings = null ) {
self::$plugin = $plugin;
if ( null !== $settings ) {
self::$settings = $settings;
}
}
/**
* Initialize settings if not already set.
*/
private static function init_settings() {
if ( null === self::$settings ) {
self::$settings = Main::get_settings();
}
}
/**
* Check if the current user has any of the allowed roles.
*
* @return bool
*/
public static function user_has_allowed_role() {
self::init_settings();
// Always allow Super Admin (Multisite).
$current_user = wp_get_current_user();
if ( is_super_admin( $current_user->ID ) ) {
return true;
}
// Get current user roles.
$user_roles = (array) $current_user->roles;
// Merge fixed always-allowed and configurable allowed roles.
$allowed_roles = array_merge(
array( 'administrator', 'shop_manager' ),
self::$settings['article']['allowed_roles']
);
// Check if any of user's roles are in the allowed list.
return (bool) array_intersect( $user_roles, $allowed_roles );
}
/**
* Check if homepage uses Blog mode
*
* @return bool
*/
public static function is_homepage_blog_posts() {
return is_home() && 'posts' === get_option( 'show_on_front', false );
}
/**
* Check if the current singular post type is enabled in plugin settings.
*
* @return bool
*/
public static function is_supported_singular_post_type() {
self::init_settings();
$singular_post_type = self::get_singular_post_type();
return $singular_post_type && in_array( $singular_post_type, self::$settings['article']['post_types'], true );
}
/**
* Check if current queried request is on supported taxonomy page
*
* @return bool
*/
public static function is_supported_taxonomy() {
self::init_settings();
$queried_object = get_queried_object();
return ( is_category() || is_tag() || is_tax() )
&& isset( $queried_object->taxonomy )
&& in_array( $queried_object->taxonomy, self::$settings['article']['taxonomies'], true );
}
/**
* Determine should we print site-wide code
* or it should be replaced with homepage/article/taxonomy code.
*
* @param string $behavior Behavior for article specific code (replace/append).
* @param string $code Article specific custom code.
* @param string $post_type Post type of current article.
* @param array $post_types Array of post types where article specific code is enabled.
* @param boolean $is_taxonomy Indicate if current displayed page is taxonomy or not.
* @return boolean Boolean that determine should site-wide code be printed (true) or not (false).
*/
public static function is_printable_sitewide_v1(
$behavior = 'append',
$code = '',
$post_type = null,
$post_types = array(),
$is_taxonomy = false
) {
// Always print if not replacing.
if ( 'replace' !== $behavior ) {
return true;
}
// If replacing but code is empty, still print sitewide.
if ( empty( $code ) ) {
return true;
}
// If replacing on non-supported post type, print sitewide.
if ( ! $is_taxonomy && ! in_array( $post_type, $post_types, true ) ) {
return true;
}
// Otherwise, don't print sitewide (it's being replaced).
return false;
}
/**
* Determine should we print site-wide code
* or it should be replaced with homepage/article/taxonomy code.
*
* @param string $behavior Behavior for article specific code (replace/append).
* @param string $code Article specific custom code.
* @param string $post_type Post type of current article.
* @param array $post_types Array of post types where article specific code is enabled.
* @param boolean $is_taxonomy Indicate if current displayed page is taxonomy or not.
* @return boolean Boolean that determine should site-wide code be printed (true) or not (false).
*/
public static function is_printable_sitewide(
$behavior = 'append',
$code = '',
$post_type = null,
$post_types = array(),
$is_taxonomy = false
) {
// Always print if not replacing.
if ( 'replace' !== $behavior ) {
return true;
}
// If replacing but code is empty, still print sitewide.
if ( empty( $code ) ) {
return true;
}
// Check if we're on homepage in blog mode.
$is_homepage_blog_posts = self::is_homepage_blog_posts();
// On homepage with replace behavior and non-empty code, don't print sitewide.
if ( $is_homepage_blog_posts ) {
return false;
}
// On taxonomy with replace behavior and non-empty code, don't print sitewide.
if ( $is_taxonomy ) {
return false;
}
// If replacing on non-supported post type, print sitewide.
if ( ! in_array( $post_type, $post_types, true ) ) {
return true;
}
// We're on a supported post type with replace behavior and non-empty code.
// Don't print sitewide (it's being replaced).
return false;
}
/**
* Function to check if code should be added on paged homepage in Blog mode
*
* @param bool $is_homepage_blog_posts If current page is blog homepage.
* @param array $settings Plugin settings (optional, uses static if not provided).
*
* @return bool
*/
public static function is_addable_to_paged_homepage( $is_homepage_blog_posts, $settings = null ) {
// Use provided settings or fall back to static settings.
if ( null === $settings ) {
self::init_settings();
$settings = self::$settings;
}
if (
true === $is_homepage_blog_posts
&& is_paged()
&& ! empty( $settings['homepage']['paged'] )
&& 'no' === $settings['homepage']['paged']
) {
return false;
}
return true;
}
/**
* Sanitizes an HTML classnames to ensure it only contains valid characters.
*
* Strips the string down to A-Z,a-z,0-9,_,-, . If this results in an empty
* string then it will return the alternative value supplied.
*
* @param string $classes The classnames to be sanitized (multiple classnames separated by space).
* @param string $fallback Optional. The value to return if the sanitization ends up as an empty string.
* Defaults to an empty string.
*
* @return string The sanitized value.
*/
public static function sanitize_html_classes( $classes, $fallback = '' ) {
// Strip out any %-encoded octets.
$sanitized = preg_replace( '|%[a-fA-F0-9][a-fA-F0-9]|', '', $classes );
// Limit to A-Z, a-z, 0-9, '_', '-' and ' ' (for multiple classes).
$sanitized = trim( preg_replace( '/[^A-Za-z0-9\_\ \-]/', '', $sanitized ) );
if ( '' === $sanitized && $fallback ) {
return self::sanitize_html_classes( $fallback );
}
return $sanitized;
}
/**
* Defines the expanded schema of allowed HTML tags and attributes for KSES.
*
* Extends the default `post` global with specific attributes required for
* modern tracking scripts, preloading (fetchpriority, imagesrcset),
* and security (nonce, integrity).
*
* @return array Map of allowed tags and their permitted attributes.
*/
public static function allowed_html() {
// Return cached value if already initialized.
if ( null !== self::$allowed_html ) {
return self::$allowed_html;
}
// Allow safe HTML, JS, and CSS.
self::$allowed_html = array_replace_recursive(
wp_kses_allowed_html( 'post' ), // Allow safe HTML for posts.
array(
'noscript' => true,
// Allow <script> tags.
'script' => array(
'type' => true,
'async' => true,
'defer' => true,
'src' => true, // remote
'crossorigin' => true, // security
'nonce' => true, // security
'charset' => true,
// global
'id' => true,
'class' => true,
'dir' => true,
'data-*' => true,
),
// Allow <style> tags.
'style' => array(
'media' => true,
'type' => true,
'scoped' => true,
'nonce' => true,
'title' => true,
// global
'id' => true,
'class' => true,
'dir' => true,
'data-*' => true,
),
// Allow <link> tags for CSS and preloading.
'link' => array(
'href' => true,
'rel' => true,
'media' => true,
'hreflang' => true,
'type' => true,
'sizes' => true,
'title' => true,
'fetchpriority' => true, // preload
'as' => true, // preload
'imagesrcset' => true, // preload for images
'imagesizes' => true, // preload for images
'crossorigin' => true, // security
'nonce' => true, // security
'itemprop' => true, // for structured data
'referrerpolicy' => true, // security
'integrity' => true, // security
// global
'id' => true,
'class' => true,
'dir' => true,
'data-*' => true,
),
// Allow <meta> tags.
'meta' => array(
'name' => true,
'http-equiv' => true,
'content' => true,
'charset' => true,
'itemprop' => true,
'media' => true,
'property' => true,
// global
'id' => true,
'class' => true,
'dir' => true,
'data-*' => true,
),
// Allow <iframe> for GTag and custom embeds.
'iframe' => array(
// standard
'src' => true,
'srcdoc' => true,
'name' => true,
'sandbox' => true,
'seamless' => true,
'width' => true,
'height' => true,
// global
'class' => true,
'hidden' => true,
'id' => true,
'style' => true,
'loading' => true,
'dir' => true,
'data-*' => true,
),
)
);
return self::$allowed_html;
}
/**
* Define allowed FORM HTML for wp_kses
*
* @return array
*/
public static function form_allowed_html() {
return array(
'fieldset' => array(),
'label' => array(
'for' => array(),
),
'input' => array(
'type' => array(),
'name' => array(),
'id' => array(),
'value' => array(),
'class' => array(),
'min' => array(), // number
'max' => array(), // number
'step' => array(), // number
'checked' => array(), // checkbox
'required' => true,
'minlength' => true,
'maxlength' => true,
'size' => true,
),
'select' => array(
'id' => array(),
'name' => array(),
'class' => array(),
),
'option' => array(
'value' => array(),
'selected' => array(),
),
'textarea' => array(
'name' => array(),
'id' => array(),
'rows' => array(),
'class' => array(),
'title' => array(),
'style' => array(),
),
'p' => array(
'class' => true,
),
'a' => array(
'href' => array(),
'target' => array( '_blank' ),
'class' => true,
'title' => true,
),
'code' => array(),
'br' => array(),
'strong' => array(),
'em' => array(),
'pre' => array(),
'span' => array(
'class' => true,
),
'i' => true,
);
}
/**
* Sanitizes HTML content while preserving script and style tag integrity.
*
* This method employs a placeholder strategy: it extracts `<script>` and `<style>`
* blocks, sanitizes their attributes, and hides them from `wp_kses()` to prevent
* the stripping of valid JS/CSS logic. After the remaining HTML is sanitized,
* the blocks are reinstated.
*
* @param string $content The raw HTML/JS/CSS content to sanitize.
* @return string Sanitized content with preserved safe scripts/styles.
*/
public static function sanitize_html_with_scripts( $content ) {
$allowed_html = self::allowed_html();
$placeholders = array();
// Extract <script> and <style> blocks.
$regex = '#<(script|style)\b[^>]*>.*?</\1>#is';
$content = preg_replace_callback(
$regex,
function ( $matches ) use ( &$placeholders, $allowed_html ) {
$full_tag = $matches[0];
$tag_name = strtolower( $matches[1] ); // script or style
// Extract opening tag for improved security, e.g. <script onload="…">
if ( preg_match( '/^<' . $tag_name . '[^>]*>/i', $full_tag, $tag_match ) ) {
$opening_tag = $tag_match[0];
$sanitized_opening_tag = wp_kses( $opening_tag, array( $tag_name => $allowed_html[ $tag_name ] ) );
if ( ! empty( $sanitized_opening_tag ) ) {
$full_tag = str_replace( $opening_tag, $sanitized_opening_tag, $full_tag );
}
}
$placeholder = '__TWU_' . strtoupper( $tag_name ) . '_PLACEHOLDER_' . count( $placeholders ) . '__';
$placeholders[ $placeholder ] = $full_tag;
return $placeholder;
},
$content
);
// Extract Google PageMap XML comment blocks before wp_kses strips comments.
$content = preg_replace_callback(
'/<!--\s*<PageMap\b[^>]*>.*?<\/PageMap>\s*-->/is',
function ( $matches ) use ( &$placeholders ) {
$placeholder = '__TWU_PAGEMAP_PLACEHOLDER_' . count( $placeholders ) . '__';
$placeholders[ $placeholder ] = self::sanitize_pagemap( $matches[0] );
return $placeholder;
},
$content
);
// Sanitize rest of content (outside scripts/styles).
$content = wp_kses( $content, $allowed_html );
// Reinstate all placeholders.
if ( ! empty( $placeholders ) ) {
$content = str_replace( array_keys( $placeholders ), array_values( $placeholders ), $content );
}
return $content;
}
/**
* Sanitize the whole HFC data array (for posts, terms, settings)
*
* @since 1.5.0
*
* @param array $input The raw $_POST['auhfc'] data.
* @return array Sanitized data.
*/
public static function sanitize_hfc_data( $input ) {
if ( ! is_array( $input ) ) {
return array();
}
// Temporarily remove Jetpack filter that may interfere with wp_kses.
$jetpack_filter = array( 'Filter_Embedded_HTML_Objects', 'maybe_create_links' );
$has_jetpack = is_callable( $jetpack_filter );
if ( $has_jetpack ) {
remove_filter( 'pre_kses', $jetpack_filter, 100 );
}
// Build sanitized data array.
$sanitized = array(
'behavior' => isset( $input['behavior'] ) ? sanitize_key( $input['behavior'] ) : 'append',
'head' => isset( $input['head'] ) ? self::sanitize_html_with_scripts( $input['head'] ) : '',
'body' => isset( $input['body'] ) ? self::sanitize_html_with_scripts( $input['body'] ) : '',
'footer' => isset( $input['footer'] ) ? self::sanitize_html_with_scripts( $input['footer'] ) : '',
);
// Reinstate Jetpack filter.
if ( $has_jetpack ) {
add_filter( 'pre_kses', $jetpack_filter, 100 );
}
return $sanitized;
}
/**
* Sanitize a Google PageMap XML comment block.
*
* Validates that only legal PageMap elements and attributes are present
* before reinserting into the output. Strips anything unexpected.
*
* Structure: <!-- <PageMap> <DataObject type="..."> <Attribute name="..." value="..."/> </DataObject> </PageMap> -->
*
* @see https://developers.google.com/custom-search/docs/structured_data#using-pagemaps
*
* @param string $raw Raw PageMap comment block.
* @return string Sanitized PageMap comment block, or empty string if invalid.
*/
private static function sanitize_pagemap( $raw ) {
// Strip the comment wrapper and work with the inner XML.
$inner = preg_replace( '/^<!--\s*(.*?)\s*-->$/is', '$1', trim( $raw ) );
if ( empty( $inner ) ) {
return '';
}
// Only allow known PageMap tags and attributes.
$allowed = array(
'pagemap' => array(),
'dataobject' => array(
'type' => true,
'id' => true,
),
'attribute' => array(
'name' => true,
'value' => true,
),
);
$sanitized_inner = wp_kses( $inner, $allowed );
if ( empty( trim( $sanitized_inner ) ) ) {
return '';
}
return '<!--' . "\n" . trim( $sanitized_inner ) . "\n" . '-->';
}
/**
* Get values of metabox fields for Posts or Terms.
*
* @param string $field_name Field key.
* @param int $id Post ID or Term ID.
* @param string $type `post` or `term`.
* @return mixed
*/
public static function get_meta( $field_name, $id, $type = 'post' ) {
if ( empty( $field_name ) || empty( $id ) ) {
return ( 'behavior' === $field_name ) ? 'append' : '';
}
$meta_key = '_auhfc';
// Get meta data based on type.
$data = ( 'post' === $type )
? get_post_meta( $id, $meta_key, true )
: get_term_meta( $id, $meta_key, true );
// Check if we got array and requested key exists.
if ( is_array( $data ) && isset( $data[ $field_name ] ) ) {
// Remove slashes from escaped value (make value ready to use).
return stripslashes_deep( $data[ $field_name ] );
}
// Default for behavior.
if ( 'behavior' === $field_name ) {
return 'append';
}
return '';
}
/**
* Helper: Get post meta values.
*
* @param string $field_name Field key.
* @param int $post_id Post ID.
* @return mixed
*/
public static function get_post_meta( $field_name, $post_id ) {
return self::get_meta( $field_name, $post_id, 'post' );
}
/**
* Helper: Get term meta values.
*
* @param string $field_name Field key.
* @param int $term_id Term ID.
* @return mixed
*/
public static function get_term_meta( $field_name, $term_id ) {
return self::get_meta( $field_name, $term_id, 'term' );
}
/**
* Smart wrapper: Get meta with auto-detected ID.
*
* @param string $field_name Field key.
* @param string $type `post` or `term`.
* @return mixed
*/
public static function get_meta_auto( $field_name, $type = 'post' ) {
return self::get_meta( $field_name, get_queried_object_id(), $type );
}
/**
* Get Post Type for singular requests.
*
* @return mixed Post type slug or `false` if not on a singular page.
*/
public static function get_singular_post_type() {
return is_singular() ? get_post_type() : false;
}
/**
* Return security risk notice title and message
*
* @return array
*/
public static function get_security_risk_notice() {
return array(
'title' => __( 'WARNING!', 'head-footer-code' ),
'message' => __( 'Enter only safe, secure, and code from a trusted source. Unsafe or invalid code may break your site or pose security risks.', 'head-footer-code' ),
);
}
/**
* Helper: Get scope label.
*
* @param string $scope Scope identifier.
* @return string
*/
private static function get_scope_label( $scope ) {
$labels = array(
'h' => 'Homepage',
's' => 'Site-wide',
'a' => 'Article specific',
'c' => 'Category specific',
't' => 'Taxonomy specific',
);
return isset( $labels[ $scope ] ) ? $labels[ $scope ] : 'Unknown';
}
/**
* Helper: Get location label.
*
* @param string $location Location identifier.
* @return string
*/
private static function get_location_label( $location ) {
$labels = array(
'h' => 'HEAD',
'b' => 'BODY',
'f' => 'FOOTER',
);
return isset( $labels[ $location ] ) ? $labels[ $location ] : 'UNKNOWN';
}
/**
* Wraps text in <code> tags and escapes HTML entities.
*
* @param string $text Text to format.
* @return string
*/
public static function format_as_code( $text ) {
return sprintf( '<code>%s</code>', esc_html( $text ) );
}
/**
* Wrap code block with debugging info when WP_DEBUG is true.
*
* @param string $scope Scope of output (s - SITE WIDE, a - ARTICLE SPECIFIC, h - HOMEPAGE).
* @param string $location Location of output (h - HEAD, b - BODY, f - FOOTER).
* @param string $message Output message.
* @param string $code Code for output.
* @return string Composed string.
*/
public static function annotate_code_block(
$scope = null,
$location = null,
$message = null,
$code = null
) {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return $code;
}
if ( null === $scope || null === $location || null === $message ) {
return '';
}
$scope_label = self::get_scope_label( $scope );
$location_label = self::get_location_label( $location );
return sprintf(
'<!-- %1$s: %2$s %3$s section start (%4$s) -->%6$s%5$s%6$s<!-- %1$s: %2$s %3$s section end (%4$s) -->%6$s',
self::$plugin->name, // 1
esc_html( $scope_label ), // 2
esc_html( $location_label ), // 3
esc_html( trim( $message ) ), // 4
trim( $code ), // 5 - RAW (Pre-sanitized)
"\n" // 6
);
}
/**
* Print security risk notice
*/
public static function print_security_risk_notice() {
echo wp_kses(
self::get_security_risk_notice(),
array(
'p' => array( 'class' => true ),
'strong' => array(),
)
);
}
}