* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * WordPress_Utils class. */ class WordPress_Utils { /** * Capabilities that require super admin to grant in multisite. * These could allow privilege escalation or network-wide access. */ private static $super_admin_only_capabilities = array( // Network management 'manage_network', 'manage_sites', 'manage_network_users', 'manage_network_plugins', 'manage_network_themes', 'manage_network_options', 'upgrade_network', // Core/plugin/theme file editing and updates 'update_core', 'update_plugins', 'update_themes', 'install_plugins', 'install_themes', 'delete_plugins', 'delete_themes', 'edit_files', 'edit_plugins', 'edit_themes', // Dangerous content capabilities 'unfiltered_html', 'unfiltered_upload', ); /** * Get list of capabilities that require super admin to grant in multisite * * @return array List of dangerous capability names * @since 1.0.0 */ public static function get_super_admin_only_capabilities() { return self::$super_admin_only_capabilities; } /** * Check if a capability requires super admin to grant in multisite * * @param string $capability Capability name to check * @return bool True if capability requires super admin * @since 1.0.0 */ public static function is_super_admin_only_capability( $capability ) { return in_array( $capability, self::$super_admin_only_capabilities, true ); } /** * Check if current user can grant a specific capability * * In multisite, non-super-admins cannot grant dangerous capabilities. * * @param string $capability Capability to check * @return bool True if current user can grant this capability * @since 1.0.0 */ public static function can_grant_capability( $capability ) { return ! is_multisite() || is_super_admin() || ! self::is_super_admin_only_capability( $capability ); } /** * Filter capabilities to only those the current user can grant * * @param array $capabilities List of capabilities to filter * @return array Filtered list of capabilities * @since 1.0.0 */ public static function filter_grantable_capabilities( $capabilities ) { if ( ! is_multisite() || is_super_admin() ) { return $capabilities; } return array_filter( $capabilities, array( self::class, 'can_grant_capability' ) ); } /** * Get roles the current user can assign * * Uses WordPress's get_editable_roles() which respects the 'editable_roles' filter. * This is the same mechanism WordPress admin uses to restrict role assignment. * * @return array Associative array of role slug => role data * @since 1.0.0 */ public static function get_assignable_roles() { if ( ! function_exists( 'get_editable_roles' ) ) { require_once ABSPATH . 'wp-admin/includes/user.php'; } return get_editable_roles(); } /** * Check if current user can assign a specific role * * @param string $role Role slug to check * @return bool True if current user can assign this role * @since 1.0.0 */ public static function can_assign_role( $role ) { $editable_roles = self::get_assignable_roles(); return isset( $editable_roles[ $role ] ); } /** * Check if current user can edit a specific user * * Wraps current_user_can('edit_user', $user_id) which uses map_meta_cap() * to properly check multisite and user-specific permissions. * * @param int $user_id User ID to check * @return bool True if current user can edit this user * @since 1.0.0 */ public static function can_edit_user( $user_id ) { return current_user_can( 'edit_user', $user_id ); } /** * Check if current user can promote/demote a specific user * * Uses 'promote_user' meta capability which WordPress maps appropriately. * * @param int $user_id User ID to check * @return bool True if current user can change this user's role * @since 1.0.0 */ public static function can_promote_user( $user_id ) { return current_user_can( 'promote_users' ) && current_user_can( 'promote_user', $user_id ); } /** * Check if current user can delete a specific user * * @param int $user_id User ID to check * @return bool True if current user can delete this user * @since 1.0.0 */ public static function can_delete_user( $user_id ) { if ( ! current_user_can( 'delete_users' ) ) { return false; } // Cannot delete yourself if ( get_current_user_id() === $user_id ) { return false; } // Cannot delete super admins in multisite if ( is_multisite() && is_super_admin( $user_id ) ) { return false; } return true; } /** * Set a featured image from a URL * * Downloads the image and sets it as the featured image for the given post. * * @param int $post_id The post ID to set the featured image for * @param string $image_url The URL of the image to download * @return int|false The attachment ID on success, false on failure * @since 1.0.0 */ public static function set_featured_image_from_url( $post_id, $image_url ) { if ( ! function_exists( 'media_sideload_image' ) ) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } $attachment_id = media_sideload_image( $image_url, $post_id, '', 'id' ); if ( is_wp_error( $attachment_id ) ) { return false; } set_post_thumbnail( $post_id, $attachment_id ); return $attachment_id; } /** * Convert an absolute file path to a path relative to WordPress root. * * @param string $file The absolute file path. * @return string The relative path, or original path if not within ABSPATH. * @since 1.0.0 */ public static function get_relative_path( $file ) { $abspath = ABSPATH; if ( strncmp( $file, $abspath, strlen( $abspath ) ) === 0 ) { return substr( $file, strlen( $abspath ) ); } return $file; } /** * Get directories that tools are allowed to access. These are mainly paths that belong to * the current WordPress instance and the temporary system directory. * * WP_PLUGIN_DIR is included separately because it can be configured outside WP_CONTENT_DIR. * * @return array Map of friendly name => absolute directory path. * @since 1.0.1 */ public static function get_allowed_paths() { return array( 'WordPress root' => ABSPATH, 'WordPress content directory' => WP_CONTENT_DIR, 'WordPress plugins directory' => WP_PLUGIN_DIR, 'System temporary directory' => get_temp_dir(), ); } /** * Get directories that tools are allowed to save files to. * * Restricted per WordPress.org plugin guidelines to the uploads directory * and the system temp directory. * * @return array Map of friendly name => absolute directory path. * @since 1.0.1 */ public static function get_allowed_save_paths() { $uploads = wp_upload_dir( null, false ); return array( 'WordPress uploads directory' => $uploads['basedir'], 'System temporary directory' => get_temp_dir(), ); } /** * Check if a path is within allowed directories and resolve it. * * At least some prefix of the path must exist for resolution to succeed. * * @param string $path The file path to check. Can be absolute or relative to ABSPATH. * @return string|false The resolved absolute path if allowed, or false if outside allowed directories. * @since 1.0.0 */ public static function is_allowed_path( $path ) { return self::resolve_within( $path, self::get_allowed_paths() ); } /** * Check if a path is within allowed save-friendly directories and resolve it. * * At least some prefix of the path must exist for resolution to succeed. * * @param string $path The file path to check. Can be absolute or relative to ABSPATH. * @return string|false The resolved absolute path if allowed, or false if outside allowed directories. * @since 1.0.1 */ public static function is_allowed_save_path( $path ) { return self::resolve_within( $path, self::get_allowed_save_paths() ); } /** * Resolve a path and check if it's within any of the given root directories. * * @param string $path The file path to check. * @param string[] $allowed_roots List of absolute root directory paths. * @return string|false The resolved absolute path if allowed, or false otherwise. * @since 1.0.1 */ private static function resolve_within( $path, array $allowed_roots ) { // Resolve the path (works even if suffix doesn't exist) $resolved = PHP_Utils::imaginary_path( $path ); if ( false === $resolved ) { return false; } // Check if within any allowed directory foreach ( $allowed_roots as $root ) { $real_root = realpath( $root ); if ( false !== $real_root && PHP_Utils::is_path_within( $resolved, $real_root ) ) { return $resolved; } } return false; } /** * Check whether a file path resolves inside the current site's uploads directory. * * wp_upload_dir() is per-site in multisite, so this is automatically scoped * to the active blog. Uses PHP_Utils::imaginary_path() to support paths * whose leaf may not yet exist. * * @param string $path Absolute or relative file path. * @return bool True when the resolved path sits inside the uploads basedir. * @since 1.0.1 */ public static function is_in_uploads( $path ) { $uploads = wp_upload_dir( null, false ); if ( ! empty( $uploads['error'] ) ) { return false; } $real_base = realpath( $uploads['basedir'] ); if ( false === $real_base ) { return false; } $resolved = PHP_Utils::imaginary_path( $path ); if ( false === $resolved ) { return false; } return PHP_Utils::is_path_within( $resolved, $real_base ); } /** * Get Media Library attachment IDs that intersect a given file or directory path. * * For a file path, matches the main attached file (stored in `_wp_attached_file` * post meta as a uploads-basedir-relative path), sub-sizes that strip off the * `-WxH` suffix (e.g. `image-150x150.jpg` -> `image.jpg`), and scaled originals * whose attached copy is `-scaled.`; resolution uses a single IN() * query across all candidates and yields at most one ID. * * For a directory path, matches every attachment whose uploads-relative path * begins with the directory (left-anchored LIKE on `_wp_attached_file`). * * @param string $path Absolute file or directory path to look up. * @return int[] Attachment IDs, or empty array if the path is outside uploads * or does not intersect any attachment. * @since 1.0.1 */ public static function get_attachment_ids_for_path( $path ) { $uploads = wp_upload_dir( null, false ); if ( ! empty( $uploads['error'] ) ) { return array(); } $basedir = realpath( $uploads['basedir'] ); $real = realpath( $path ); if ( false === $basedir || false === $real || ! PHP_Utils::is_path_within( $real, $basedir ) ) { return array(); } // Meta stores uploads-relative paths with forward slashes on every OS. $relative = ltrim( substr( $real, strlen( $basedir ) ), DIRECTORY_SEPARATOR ); $relative = str_replace( DIRECTORY_SEPARATOR, '/', $relative ); global $wpdb; // Directory: prefix-match every attachment path under it. if ( is_dir( $real ) ) { $prefix = '' === $relative ? '' : $relative . '/'; $like = $wpdb->esc_like( $prefix ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query against postmeta with fixed meta_key; value is passed through wpdb->prepare. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s", $like ) ); return array_map( 'intval', $ids ); } // File: match main, sub-size (strip -WxH), or -scaled original. Operate on // the basename stem so the sub-size regex cannot match in a directory name; // mirrors the basename-first idiom used by core in wp_calculate_image_srcset(). $candidates = array( $relative ); $ext = pathinfo( wp_basename( $relative ), PATHINFO_EXTENSION ); if ( '' !== $ext ) { $stem = pathinfo( wp_basename( $relative ), PATHINFO_FILENAME ); $dirname = dirname( $relative ); $dir = ( '.' === $dirname || '' === $dirname ) ? '' : $dirname . '/'; $dot_ext = '.' . $ext; $without_size = preg_replace( '/-\d+x\d+$/', '', $stem ); if ( $without_size !== $stem ) { $candidates[] = $dir . $without_size . $dot_ext; } if ( '-scaled' !== substr( $without_size, -7 ) ) { $candidates[] = $dir . $without_size . '-scaled' . $dot_ext; } } $placeholders = implode( ',', array_fill( 0, count( $candidates ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query against postmeta with hardcoded meta_key; caching not applicable. $id = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- IN-list placeholders built from fixed-size array of '%s' literals; values passed through prepare() via splat. "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value IN ($placeholders) LIMIT 1", ...$candidates ) ); return $id > 0 ? array( $id ) : array(); } /** * Format a map of named paths for display in LLM tool descriptions. * * @param array $paths Map of friendly name => absolute directory path. * @return string Comma-separated "Friendly Name (absolute path)" entries. * @since 1.0.1 */ public static function format_paths_description( array $paths ) { $parts = array(); foreach ( $paths as $name => $path ) { $parts[] = $name . ' (' . rtrim( $path, DIRECTORY_SEPARATOR ) . ')'; } return implode( ', ', $parts ); } /** * Get the WordPress filesystem instance. * * Initializes WP_Filesystem if not already done and returns the global $wp_filesystem. * * @return \WP_Filesystem_Base The filesystem instance. * @throws \RuntimeException If WP_Filesystem initialization fails. * @since 1.0.0 */ public static function get_wp_filesystem() { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! WP_Filesystem() ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \RuntimeException( __( 'Failed to initialize WP_Filesystem', 'agenistrator' ) ); } return $wp_filesystem; } /** * Send JSON error response for an exception * * Handles both Ajax_Exception (with HTTP status and error codes) and generic exceptions. * This method does not return - it calls wp_send_json_error() which exits. * * @param \Throwable $e The exception to send as JSON error. * @return never * @since 1.0.0 */ public static function send_json_exception( \Throwable $e ) { $data = array( 'message' => $e->getMessage() ); if ( $e instanceof Ajax_Exception ) { if ( $e->get_error_code() ) { $data['code'] = $e->get_error_code(); } wp_send_json_error( $data, $e->get_http_status() ); } wp_send_json_error( $data ); } /** * Extract the plugin slug (directory name) from the plugin's main PHP file path. * * WordPress plugins are identified by paths like "my-plugin/my-plugin.php". * This function extracts the slug (directory name) portion. * * Note: We intentionally avoid sanitization functions like sanitize_text_field() * or sanitize_file_name() because they can modify the actual directory name * (e.g., stripping %XX patterns, collapsing dashes), which would break path * construction. Security is ensured by: * - find_plugin() validates the plugin exists in WordPress's registry * - realpath() + is_path_within() validates constructed paths * * @param string $plugin The plugin identifier (e.g., "my-plugin/my-plugin.php" or "my-plugin"). * @return string The plugin slug (directory name portion). * @since 1.0.0 */ public static function get_plugin_slug( $plugin ) { // Extract slug from plugin file path (e.g., "my-plugin/my-plugin.php" -> "my-plugin") // strtok() returns the first component before '/', which is the directory name return strtok( $plugin, '/' ); } /** * Find an installed plugin by various matching strategies. * * This method handles LLM input that could be in various formats: * - Full plugin basename (e.g., "akismet/akismet.php") * - Plugin slug/directory name (e.g., "akismet") * - Plugin name (e.g., "Akismet Anti-spam") * - Single-file plugin name (e.g., "hello" for "hello.php") * * Matching priority: * 1. Direct match on plugin basename (exact file path) * 2. Match against directory portion of plugin basename * 3. Match single-file plugins (e.g., "hello" matches "hello.php") * 4. Case-insensitive substring match on plugin name * * @param string $identifier The plugin identifier from LLM input. * @param array $all_plugins Optional. Array from get_plugins(). If not provided, will be fetched. * @return array|null Array with 'file' and 'data' keys if found, null otherwise. * @since 1.0.0 */ public static function find_plugin( $identifier, $all_plugins = null ) { if ( null === $all_plugins ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); } // Sanitize the identifier to get a clean slug for comparisons $input_slug = self::get_plugin_slug( $identifier ); // Strategy 1: Direct match on plugin basename (e.g., "akismet/akismet.php") if ( isset( $all_plugins[ $identifier ] ) ) { return array( 'file' => $identifier, 'data' => $all_plugins[ $identifier ], ); } // Iterate through all plugins trying other matching strategies foreach ( $all_plugins as $file => $data ) { // Strategy 2: Match against directory portion of plugin basename $file_slug = self::get_plugin_slug( $file ); if ( $file_slug === $input_slug ) { return array( 'file' => $file, 'data' => $data, ); } // Strategy 3: Handle single-file plugins (no directory) // For plugins like "hello.php" in the plugins root directory // Input "hello" should match file "hello.php" if ( strpos( $file, '/' ) === false && $file === $input_slug . '.php' ) { return array( 'file' => $file, 'data' => $data, ); } } // Strategy 4: Case-insensitive substring match on plugin name // This is the last resort as it's the least precise foreach ( $all_plugins as $file => $data ) { if ( stripos( $data['Name'], $identifier ) !== false ) { return array( 'file' => $file, 'data' => $data, ); } } return null; } /** * Discover available error log files. * * Checks multiple locations where PHP/WordPress error logs may exist. * Priority: PHP error_log setting, WP_DEBUG_LOG path, default debug.log, common fallbacks. * * @return array Array of absolute paths to existing, readable log files. * @since 1.0.0 */ public static function discover_log_files() { $log_files = array(); // 1. PHP's configured error log $php_error_log = ini_get( 'error_log' ); if ( ! empty( $php_error_log ) && file_exists( $php_error_log ) ) { $log_files[] = $php_error_log; } // 2. WP_DEBUG_LOG custom path (if string) if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && file_exists( WP_DEBUG_LOG ) ) { $log_files[] = WP_DEBUG_LOG; } // 3. Default WordPress debug log $wp_debug_log = WP_CONTENT_DIR . '/debug.log'; if ( file_exists( $wp_debug_log ) ) { $log_files[] = $wp_debug_log; } // 4. Common fallback locations $common_locations = array( ABSPATH . 'error_log', WP_CONTENT_DIR . '/error.log', ); foreach ( $common_locations as $path ) { if ( file_exists( $path ) ) { $log_files[] = $path; } } return array_values( array_unique( $log_files ) ); } /** * Check if Action Scheduler is available and initialized * * @return bool True if Action Scheduler is ready to use * @since 1.0.0 */ public static function is_action_scheduler_available() { return function_exists( 'as_get_scheduled_actions' ) && class_exists( 'ActionScheduler' ) && \ActionScheduler::is_initialized(); } /** * Get Action Scheduler status and statistics * * @return array Action Scheduler status data * @since 1.0.0 */ public static function get_action_scheduler_status() { if ( ! self::is_action_scheduler_available() ) { return array( 'available' => false ); } $data = array( 'available' => true, 'version' => \ActionScheduler_Versions::instance()->latest_version(), 'counts_by_status' => array(), ); try { $store = \ActionScheduler::store(); $statuses = array( 'pending', 'in-progress', 'complete', 'failed', 'canceled' ); foreach ( $statuses as $status ) { $data['counts_by_status'][ $status ] = (int) $store->query_actions( array( 'status' => $status, 'per_page' => 0, ), 'count' ); } } catch ( \Throwable $e ) { $data['error'] = $e->getMessage(); } return $data; } }