$upload_dir['basedir'] . static::get_base_dir() . $entity_id, 'baseurl' => $upload_dir['baseurl'] . static::get_base_dir() . $entity_id, ); } /** * Get a cached file URL if it exists. * * @param string $url The remote URL. * @param string|int $entity_id The entity identifier. * * @return string|false The local URL if cached, false otherwise. */ public static function get( $url, $entity_id ) { if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) { return false; } $paths = static::get_storage_paths( $entity_id ); if ( ! \is_dir( $paths['basedir'] ) ) { return false; } $hash = static::generate_hash( $url ); $pattern = static::escape_glob_pattern( $paths['basedir'] . '/' . $hash ) . '.*'; $matches = \glob( $pattern ); if ( ! empty( $matches ) && \is_file( $matches[0] ) ) { return $paths['baseurl'] . '/' . \basename( $matches[0] ); } return false; } /** * Get a cached file or cache it if not present. * * This is the main entry point for lazy caching. Called via filter hooks. * * @param string $url The remote URL. * @param string|int $entity_id The entity identifier. * @param array $options Optional. Additional options like 'updated' timestamp. * * @return string|false The local URL on success, false on failure. */ public static function get_or_cache( $url, $entity_id, $options = array() ) { if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) { return false; } // Check if already cached. $cached_url = static::get( $url, $entity_id ); if ( $cached_url ) { // Check for staleness if updated timestamp provided. if ( ! empty( $options['updated'] ) ) { $paths = static::get_storage_paths( $entity_id ); $hash = static::generate_hash( $url ); $pattern = static::escape_glob_pattern( $paths['basedir'] . '/' . $hash ) . '.*'; $matches = \glob( $pattern ); $file_path = ( $matches && \is_file( $matches[0] ) ) ? $matches[0] : null; $local_time = $file_path ? \filemtime( $file_path ) : 0; $remote_time = \strtotime( $options['updated'] ); if ( $remote_time && $local_time >= $remote_time ) { return $cached_url; } // Stale - continue to re-download. } else { return $cached_url; } } // Download and cache the file. return static::cache( $url, $entity_id, $options ); } /** * Cache a remote file locally. * * Downloads the file, validates it, optimizes images, and stores locally. * * @param string $url The remote URL. * @param string|int $entity_id The entity identifier. * @param array $options Optional. Additional options. * * @return string|false The local URL on success, false on failure. */ public static function cache( $url, $entity_id, $options = array() ) { $result = static::download_and_validate( $url ); if ( \is_wp_error( $result ) || empty( $result['file'] ) ) { return false; } $tmp_file = $result['file']; $paths = static::get_storage_paths( $entity_id ); // Create directory if it doesn't exist. if ( ! \wp_mkdir_p( $paths['basedir'] ) ) { \wp_delete_file( $tmp_file ); return false; } // Generate hash-based filename. $hash = static::generate_hash( $url ); $ext = \pathinfo( $tmp_file, PATHINFO_EXTENSION ); if ( empty( $ext ) ) { $ext = \wp_get_default_extension_for_mime_type( $result['mime_type'] ); } $file_name = $hash . '.' . $ext; $file_path = $paths['basedir'] . '/' . $file_name; // Move file to destination. if ( ! static::get_filesystem()->move( $tmp_file, $file_path, true ) ) { \wp_delete_file( $tmp_file ); return false; } // Optimize image if applicable. $max_dimension = $options['max_dimension'] ?? static::get_max_dimension(); $file_path = static::optimize_image( $file_path, $max_dimension ); $file_name = \basename( $file_path ); $local_url = $paths['baseurl'] . '/' . $file_name; /** * Fires after a remote media file has been successfully cached. * * Use this hook for logging, analytics, or post-processing. * * @since 5.6.0 * * @param string $local_url The local URL of the cached file. * @param string $url The original remote URL. * @param string|int $entity_id The entity identifier. * @param string $type The cache type ('avatar', 'media', 'emoji'). * @param string $file_path The local file system path. */ \do_action( 'activitypub_media_cached', $local_url, $url, $entity_id, static::get_type(), $file_path ); return $local_url; } /** * Invalidate cached files for an entity. * * Deletes the entire entity directory and all its contents. * * @param string|int $entity_id The entity identifier. * * @return bool True on success, false on failure. */ public static function invalidate_entity( $entity_id ) { $paths = static::get_storage_paths( $entity_id ); return static::delete_directory( $paths['basedir'] ); } /** * Get a direct filesystem instance. * * Uses WP_Filesystem_Direct explicitly instead of WP_Filesystem(), * which may fall back to FTP on servers where ABSPATH is not writable. * The uploads directory (where cache files live) is always writable by * the web server — the same assumption WordPress core makes for media * uploads in _wp_handle_upload(). * * @since 8.0.0 * * @return \WP_Filesystem_Direct The direct filesystem instance. */ protected static function get_filesystem() { static $filesystem = null; if ( null === $filesystem ) { require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; $filesystem = new \WP_Filesystem_Direct( null ); } return $filesystem; } /** * Delete a directory and all its contents. * * @since 8.0.0 * * @param string $basedir The directory path to delete. * * @return bool True on success or if directory doesn't exist, false on failure. */ public static function delete_directory( $basedir ) { if ( ! \is_dir( $basedir ) ) { return true; } return static::get_filesystem()->rmdir( $basedir, true ); } /** * Generate a hash for a URL. * * Uses full MD5 hash (32 characters) for better collision resistance. * With truncated hashes, collision probability increases significantly * at scale. * * @param string $url The URL to hash. * * @return string The full MD5 hash string (32 characters). */ protected static function generate_hash( $url ) { return \md5( $url ); } /** * Escape glob metacharacters in a pattern. * * This prevents special characters (*, ?, [, ]) from being interpreted * as glob patterns when searching for files. * * @param string $pattern The pattern to escape. * * @return string The escaped pattern safe for use in glob(). */ protected static function escape_glob_pattern( $pattern ) { return \preg_replace( '/([*?\[\]])/', '[$1]', $pattern ); } /** * Validate a URL is safe to fetch. * * @param string $url The URL to validate. * * @return bool True if URL is safe to fetch, false otherwise. */ protected static function is_safe_url( $url ) { if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) { return false; } /** * Filters whether a URL passes safety validation. * * By default, uses wp_http_validate_url() which prevents SSRF attacks * by blocking private IPs and localhost. This filter allows overriding * for testing or custom validation needs. * * @since 5.6.0 * * @param bool|null $is_safe Whether the URL is safe. Return true/false to override, * or null to use default wp_http_validate_url() check. * @param string $url The URL being validated. */ $is_safe = \apply_filters( 'activitypub_cache_is_safe_url', null, $url ); if ( null !== $is_safe ) { return (bool) $is_safe; } return (bool) \wp_http_validate_url( $url ); } /** * Get allowed MIME types for this cache type. * * @return array Array of allowed MIME types. */ protected static function get_allowed_mime_types() { $type = static::get_type(); /** * Filters the allowed MIME types for a cache type. * * Use this filter to add or remove allowed MIME types. * * @since 5.6.0 * * @param array $mime_types Array of allowed MIME types. * @param string $type The cache type ('avatar', 'media', 'emoji'). */ return (array) \apply_filters( 'activitypub_cache_allowed_mime_types', static::DEFAULT_ALLOWED_MIME_TYPES, $type ); } /** * Download and validate a remote file. * * @param string $url The remote URL to download. * * @return array|\WP_Error { * Array on success, WP_Error on failure. * * @type string $file Path to downloaded file. * @type string $mime_type Validated MIME type. * } */ protected static function download_and_validate( $url ) { $type = static::get_type(); /** * Filters the download result before fetching a URL. * * Allows short-circuiting the download process by providing a pre-downloaded * file path. Useful for testing or when files are already available locally. * * @since 5.6.0 * * @param array|null $result { * Return null to proceed with download, or array with file info. * * @type string $file Path to the downloaded file. * @type string $mime_type The file's MIME type. * } * @param string $url The URL that would be downloaded. * @param string $type The cache type ('avatar', 'media', 'emoji'). */ $pre_download = \apply_filters( 'activitypub_pre_download_url', null, $url, $type ); if ( null !== $pre_download ) { return $pre_download; } /** * Filters whether a URL should be cached. * * Allows preventing specific URLs from being downloaded and cached. * Return false to skip caching this URL. * * @since 5.6.0 * * @param bool $should_cache Whether to cache this URL. Default true. * @param string $url The remote URL. * @param string $type The cache type ('avatar', 'media', 'emoji'). */ $should_cache = \apply_filters( 'activitypub_should_cache_url', true, $url, $type ); if ( ! $should_cache ) { return new \WP_Error( 'cache_skipped', \__( 'URL caching was skipped by filter.', 'activitypub' ) ); } // Validate URL is safe to fetch. if ( ! static::is_safe_url( $url ) ) { return new \WP_Error( 'invalid_url', \__( 'URL is not allowed.', 'activitypub' ) ); } if ( ! \function_exists( 'download_url' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $tmp_file = \download_url( $url, 15 ); // 15 second timeout. if ( \is_wp_error( $tmp_file ) ) { return $tmp_file; } // Validate file size. $file_size = \filesize( $tmp_file ); if ( $file_size > static::MAX_FILE_SIZE ) { \wp_delete_file( $tmp_file ); return new \WP_Error( 'file_too_large', \__( 'File exceeds maximum size limit.', 'activitypub' ) ); } // Validate MIME type. $validation = static::validate_mime_type( $tmp_file ); if ( \is_wp_error( $validation ) ) { \wp_delete_file( $tmp_file ); return $validation; } // Get the validated file path (may have been renamed). $file_path = \is_string( $validation ) ? $validation : $tmp_file; $mime_type = static::get_file_mime_type( $file_path ); return array( 'file' => $file_path, 'mime_type' => $mime_type, ); } /** * Validate MIME type of a file using multiple methods. * * This method addresses potential wp_get_image_mime() bypass concerns * by using finfo, getimagesize, and wp_check_filetype_and_ext for validation. * * @param string $file_path Path to the file. * * @return string|\WP_Error File path (possibly renamed) on success, WP_Error on failure. */ protected static function validate_mime_type( $file_path ) { $allowed_mime_types = static::get_allowed_mime_types(); // Require fileinfo extension for validation. if ( ! \extension_loaded( 'fileinfo' ) ) { return new \WP_Error( 'finfo_failed', \__( 'Fileinfo extension not available.', 'activitypub' ) ); } // Method 1: Use cached finfo instance for reliable MIME detection. if ( null === self::$finfo ) { self::$finfo = new \finfo( FILEINFO_MIME_TYPE ); } $mime = self::$finfo->file( $file_path ); if ( ! \in_array( $mime, $allowed_mime_types, true ) ) { return new \WP_Error( 'invalid_mime', \__( 'File type not allowed.', 'activitypub' ) ); } // Method 2: Verify it's actually a valid image. $image_info = @\getimagesize( $file_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $image_info ) { return new \WP_Error( 'invalid_image', \__( 'File is not a valid image.', 'activitypub' ) ); } // Verify image can actually be rendered. if ( ! \function_exists( 'file_is_displayable_image' ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; } if ( ! \file_is_displayable_image( $file_path ) ) { return new \WP_Error( 'not_displayable', \__( 'Image cannot be displayed.', 'activitypub' ) ); } /* * Method 3: Use WordPress's wp_check_filetype_and_ext for additional validation. * MIME type restriction is already enforced by finfo in Method 1; this cross-checks * that file content matches the declared type using WordPress defaults. */ $expected_ext = \wp_get_default_extension_for_mime_type( $mime ); // Use the detected extension since temp files from download_url() have a .tmp extension. $file_name = \pathinfo( \wp_basename( $file_path ), PATHINFO_FILENAME ) . '.' . $expected_ext; $file_info = \wp_check_filetype_and_ext( $file_path, $file_name ); // If WordPress couldn't validate the file type, reject it. if ( empty( $file_info['type'] ) || ! \str_starts_with( $file_info['type'], 'image/' ) ) { return new \WP_Error( 'invalid_file_type', \__( 'File type validation failed.', 'activitypub' ) ); } // Method 4: Ensure file extension matches MIME type. $ext = \pathinfo( $file_path, PATHINFO_EXTENSION ); if ( strtolower( $ext ) !== $expected_ext ) { $new_path = \preg_replace( '/\.[^.]+$/', '.' . $expected_ext, $file_path ); if ( empty( $new_path ) || $new_path === $file_path ) { $new_path = $file_path . '.' . $expected_ext; } if ( static::get_filesystem()->move( $file_path, $new_path, true ) ) { return $new_path; } } return $file_path; } /** * Get the MIME type of a file. * * @param string $file_path Path to the file. * * @return string The MIME type. */ protected static function get_file_mime_type( $file_path ) { if ( \extension_loaded( 'fileinfo' ) ) { if ( null === self::$finfo ) { self::$finfo = new \finfo( FILEINFO_MIME_TYPE ); } return self::$finfo->file( $file_path ); } // Fallback to WordPress function. return \wp_check_filetype( $file_path )['type'] ?? ''; } /** * Optimize an image file by resizing and converting to WebP. * * Uses WordPress image editor to resize large images and convert them * to WebP format for better compression while maintaining quality. * * @param string $file_path Path to the image file. * @param int $max_dimension Maximum width/height in pixels. * * @return string The optimized file path. */ protected static function optimize_image( $file_path, $max_dimension ) { // Check if it's an image. $mime_type = static::get_file_mime_type( $file_path ); if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) { return $file_path; } $editor = \wp_get_image_editor( $file_path ); if ( \is_wp_error( $editor ) ) { return $file_path; } $size = $editor->get_size(); $needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension; // Resize if needed. if ( $needs_resize ) { $editor->resize( $max_dimension, $max_dimension, false ); } // Check if WebP is supported. $can_webp = $editor->supports_mime_type( 'image/webp' ); // Determine output format and save. $dir = \dirname( $file_path ); if ( $can_webp ) { // Convert to WebP. $new_name = \wp_unique_filename( $dir, \preg_replace( '/\.[^.]+$/', '.webp', \basename( $file_path ) ) ); $result = $editor->save( $dir . '/' . $new_name, 'image/webp' ); } elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) { // Keep original format for potentially transparent images when WebP not available. if ( ! $needs_resize ) { return $file_path; } $result = $editor->save( $file_path ); } else { // Convert to JPEG when WebP not available. $new_name = \wp_unique_filename( $dir, \preg_replace( '/\.[^.]+$/', '.jpg', \basename( $file_path ) ) ); $result = $editor->save( $dir . '/' . $new_name, 'image/jpeg' ); } if ( \is_wp_error( $result ) ) { return $file_path; } // Handle result. $result_path = $result['path'] ?? $file_path; // If path changed (format conversion), delete the original file. if ( $result_path !== $file_path ) { \wp_delete_file( $file_path ); } return $result_path; } }