updated plugin ActivityPub version 8.3.0
This commit is contained in:
172
wp-content/plugins/activitypub/includes/cache/class-avatar.php
vendored
Normal file
172
wp-content/plugins/activitypub/includes/cache/class-avatar.php
vendored
Normal file
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* Avatar cache class.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Cache;
|
||||
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
/**
|
||||
* Avatar cache class.
|
||||
*
|
||||
* Handles caching of remote actor avatars locally.
|
||||
* Avatars are stored in /wp-content/uploads/activitypub/actors/{actor_id}/
|
||||
* and cleaned up automatically when the actor is deleted.
|
||||
*
|
||||
* @since 5.6.0
|
||||
*/
|
||||
class Avatar extends File {
|
||||
/**
|
||||
* Maximum dimension for avatars in pixels.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_DIMENSION = 512;
|
||||
|
||||
/**
|
||||
* Context identifier for the filter.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONTEXT = 'avatar';
|
||||
|
||||
/**
|
||||
* Get the cache type identifier.
|
||||
*
|
||||
* @return string Cache type.
|
||||
*/
|
||||
public static function get_type() {
|
||||
return 'avatar';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory path relative to uploads.
|
||||
*
|
||||
* @return string Base directory path.
|
||||
*/
|
||||
public static function get_base_dir() {
|
||||
return '/activitypub/actors/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context identifier for the filter.
|
||||
*
|
||||
* @return string Context identifier.
|
||||
*/
|
||||
public static function get_context() {
|
||||
return self::CONTEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum dimension for avatars.
|
||||
*
|
||||
* @return int Maximum width/height in pixels.
|
||||
*/
|
||||
public static function get_max_dimension() {
|
||||
return self::MAX_DIMENSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cache handler.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hook into the universal remote media URL filter for lazy caching.
|
||||
\add_filter( 'activitypub_remote_media_url', array( self::class, 'maybe_cache' ), 10, 4 );
|
||||
|
||||
// Invalidate cached avatar when actor is updated so it re-downloads on next access.
|
||||
\add_action( 'save_post_' . Remote_Actors::POST_TYPE, array( self::class, 'clear_cached_avatar' ) );
|
||||
|
||||
// Clean up files when actor is deleted.
|
||||
\add_action( 'before_delete_post', array( self::class, 'maybe_cleanup' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached avatar when an actor is updated.
|
||||
*
|
||||
* Invalidates cached files so the avatar is re-downloaded on next access.
|
||||
*
|
||||
* @param int $post_id The actor post ID.
|
||||
*/
|
||||
public static function clear_cached_avatar( $post_id ) {
|
||||
// Invalidate cached files so next access re-downloads.
|
||||
self::invalidate_entity( $post_id );
|
||||
|
||||
// Clean up legacy meta from previous versions.
|
||||
\delete_post_meta( $post_id, '_activitypub_avatar_url' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe cache an avatar URL.
|
||||
*
|
||||
* Hooked to the activitypub_remote_media_url filter.
|
||||
* Uses filesystem-based caching via get_or_cache() — no persistent meta storage.
|
||||
*
|
||||
* @param string $url The remote URL.
|
||||
* @param string $context The context ('avatar', 'media', 'emoji', etc.).
|
||||
* @param string|int $entity_id The entity identifier (actor post ID).
|
||||
* @param array $options Optional. Additional options (unused for avatars).
|
||||
*
|
||||
* @return string The local URL if cached successfully, otherwise the original URL.
|
||||
*/
|
||||
public static function maybe_cache( $url, $context, $entity_id = null, $options = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for filter signature.
|
||||
if ( self::CONTEXT !== $context || empty( $url ) || empty( $entity_id ) ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$cached_url = self::get_or_cache( $url, $entity_id, array( 'max_dimension' => self::MAX_DIMENSION ) );
|
||||
|
||||
return $cached_url ?: $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe clean up cached avatar when actor is deleted.
|
||||
*
|
||||
* @param int $post_id The post ID being deleted.
|
||||
*/
|
||||
public static function maybe_cleanup( $post_id ) {
|
||||
if ( Remote_Actors::POST_TYPE !== \get_post_type( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::invalidate_entity( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an avatar for an actor.
|
||||
*
|
||||
* This is a convenience method that wraps get_or_cache with the correct options.
|
||||
* It also invalidates any existing avatar before caching the new one.
|
||||
*
|
||||
* @param int $actor_id The actor post ID.
|
||||
* @param string $avatar_url The remote avatar URL.
|
||||
*
|
||||
* @return string|false The local avatar URL on success, false on failure.
|
||||
*/
|
||||
public static function save( $actor_id, $avatar_url ) {
|
||||
// Validate actor_id is a positive integer.
|
||||
$actor_id = (int) $actor_id;
|
||||
if ( $actor_id <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( empty( $avatar_url ) || ! \filter_var( $avatar_url, FILTER_VALIDATE_URL ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete existing avatar files before saving new one.
|
||||
self::invalidate_entity( $actor_id );
|
||||
|
||||
return self::cache(
|
||||
$avatar_url,
|
||||
$actor_id,
|
||||
array( 'max_dimension' => self::MAX_DIMENSION )
|
||||
);
|
||||
}
|
||||
}
|
||||
184
wp-content/plugins/activitypub/includes/cache/class-emoji.php
vendored
Normal file
184
wp-content/plugins/activitypub/includes/cache/class-emoji.php
vendored
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
/**
|
||||
* Emoji cache class.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Cache;
|
||||
|
||||
/**
|
||||
* Emoji cache class.
|
||||
*
|
||||
* Handles file caching of custom emoji locally.
|
||||
* Emoji are stored in /wp-content/uploads/activitypub/emoji/{domain}/
|
||||
* organized by source domain for easier management.
|
||||
*
|
||||
* This class is responsible ONLY for file operations (download, validate, store, optimize).
|
||||
* Content transformation (replacing shortcodes with img tags) is handled by the main
|
||||
* Activitypub\Emoji class.
|
||||
*
|
||||
* @since 5.6.0
|
||||
*/
|
||||
class Emoji extends File {
|
||||
/**
|
||||
* Maximum dimension for emoji in pixels.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_DIMENSION = 128;
|
||||
|
||||
/**
|
||||
* Context identifier for the filter.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONTEXT = 'emoji';
|
||||
|
||||
/**
|
||||
* Base directory for emoji storage.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BASE_DIR = '/activitypub/emoji/';
|
||||
|
||||
/**
|
||||
* Get the cache type identifier.
|
||||
*
|
||||
* @return string Cache type.
|
||||
*/
|
||||
public static function get_type() {
|
||||
return 'emoji';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory path relative to uploads.
|
||||
*
|
||||
* @return string Base directory path.
|
||||
*/
|
||||
public static function get_base_dir() {
|
||||
return self::BASE_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context identifier for the filter.
|
||||
*
|
||||
* @return string Context identifier.
|
||||
*/
|
||||
public static function get_context() {
|
||||
return self::CONTEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum dimension for emoji.
|
||||
*
|
||||
* @return int Maximum width/height in pixels.
|
||||
*/
|
||||
public static function get_max_dimension() {
|
||||
return self::MAX_DIMENSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cache handler.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hook into the universal remote media URL filter.
|
||||
// This allows third-party CDN plugins to intercept emoji URLs.
|
||||
\add_filter( 'activitypub_remote_media_url', array( self::class, 'maybe_cache' ), 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe cache an emoji URL.
|
||||
*
|
||||
* Hooked to the activitypub_remote_media_url filter.
|
||||
* Delegates to import() to preserve the activitypub_pre_import_emoji filter.
|
||||
*
|
||||
* @param string $url The remote URL.
|
||||
* @param string $context The context ('avatar', 'media', 'emoji', etc.).
|
||||
* @param string|null $entity_id The entity identifier (unused for emoji, domain extracted from URL).
|
||||
* @param array $options Optional. Additional options like 'updated' timestamp.
|
||||
*
|
||||
* @return string The local URL if cached successfully, otherwise the original URL.
|
||||
*/
|
||||
public static function maybe_cache( $url, $context, $entity_id = null, $options = array() ) {
|
||||
if ( self::CONTEXT !== $context || empty( $url ) ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Delegate to import() which handles the activitypub_pre_import_emoji filter.
|
||||
$cached_url = self::import( $url, $options['updated'] ?? null );
|
||||
|
||||
return $cached_url ?: $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a remote emoji image locally.
|
||||
*
|
||||
* This is a convenience method that wraps the cache functionality
|
||||
* with staleness checking based on the updated timestamp.
|
||||
*
|
||||
* @param string $emoji_url The remote emoji URL.
|
||||
* @param string|null $updated Optional. The remote emoji's updated timestamp (ISO 8601).
|
||||
* If provided and newer than cached version, re-downloads.
|
||||
*
|
||||
* @return string|false The local emoji URL on success, false on failure.
|
||||
*/
|
||||
public static function import( $emoji_url, $updated = null ) {
|
||||
if ( empty( $emoji_url ) || ! \filter_var( $emoji_url, FILTER_VALIDATE_URL ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the result of emoji import before processing.
|
||||
*
|
||||
* Allows short-circuiting the emoji import, useful for testing.
|
||||
*
|
||||
* @since 5.6.0
|
||||
*
|
||||
* @param string|false|null $result The import result. Return a URL string to short-circuit,
|
||||
* false to indicate failure, or null to proceed normally.
|
||||
* @param string $emoji_url The remote emoji URL being imported.
|
||||
* @param string|null $updated The remote emoji's updated timestamp.
|
||||
*/
|
||||
$pre_import = \apply_filters( 'activitypub_pre_import_emoji', null, $emoji_url, $updated );
|
||||
if ( null !== $pre_import ) {
|
||||
return $pre_import;
|
||||
}
|
||||
|
||||
$domain = \wp_parse_url( $emoji_url, PHP_URL_HOST );
|
||||
if ( empty( $domain ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$options = array( 'max_dimension' => self::MAX_DIMENSION );
|
||||
if ( $updated ) {
|
||||
$options['updated'] = $updated;
|
||||
}
|
||||
|
||||
return self::get_or_cache( $emoji_url, $domain, $options );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash for an emoji URL.
|
||||
*
|
||||
* Uses full URL path hash to prevent collisions between emoji with the same
|
||||
* filename but different paths (e.g., /set1/kappa.png vs /set2/kappa.png).
|
||||
*
|
||||
* @param string $url The URL to hash.
|
||||
*
|
||||
* @return string The hash string.
|
||||
*/
|
||||
protected static function generate_hash( $url ) {
|
||||
$url_path = \wp_parse_url( $url, PHP_URL_PATH );
|
||||
if ( $url_path ) {
|
||||
return \md5( $url_path );
|
||||
}
|
||||
|
||||
// Fall back to full URL hash.
|
||||
return parent::generate_hash( $url );
|
||||
}
|
||||
}
|
||||
671
wp-content/plugins/activitypub/includes/cache/class-file.php
vendored
Normal file
671
wp-content/plugins/activitypub/includes/cache/class-file.php
vendored
Normal file
@ -0,0 +1,671 @@
|
||||
<?php
|
||||
/**
|
||||
* File cache abstract class.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Cache;
|
||||
|
||||
/**
|
||||
* Abstract file cache class.
|
||||
*
|
||||
* Provides shared functionality for caching remote media files locally.
|
||||
* Subclasses implement type-specific storage paths and initialization.
|
||||
*
|
||||
* Caching is lazy/filter-based: URLs pass through the `activitypub_remote_media_url`
|
||||
* filter, and cache handlers check if already cached or download on demand.
|
||||
*
|
||||
* @since 5.6.0
|
||||
*/
|
||||
abstract class File {
|
||||
/**
|
||||
* Maximum file size in bytes (10MB).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_FILE_SIZE = 10485760; // 10 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Default allowed MIME types for cached files.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const DEFAULT_ALLOWED_MIME_TYPES = array(
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
);
|
||||
|
||||
/**
|
||||
* Cached finfo instance for MIME type detection.
|
||||
*
|
||||
* Using a cached instance avoids repeated finfo_open() calls and
|
||||
* the deprecated finfo_close() in PHP 8.5+.
|
||||
*
|
||||
* @var \finfo|null
|
||||
*/
|
||||
private static $finfo = null;
|
||||
|
||||
|
||||
/**
|
||||
* Get the cache type identifier.
|
||||
*
|
||||
* @return string Cache type (e.g., 'avatar', 'media', 'emoji').
|
||||
*/
|
||||
abstract public static function get_type();
|
||||
|
||||
/**
|
||||
* Get the base directory path relative to uploads.
|
||||
*
|
||||
* @return string Base directory path (e.g., '/activitypub/actors/').
|
||||
*/
|
||||
abstract public static function get_base_dir();
|
||||
|
||||
/**
|
||||
* Get the context identifier for the activitypub_remote_media_url filter.
|
||||
*
|
||||
* @return string Context identifier (e.g., 'avatar', 'media', 'emoji').
|
||||
*/
|
||||
abstract public static function get_context();
|
||||
|
||||
/**
|
||||
* Get the maximum dimension for images of this type.
|
||||
*
|
||||
* @return int Maximum width/height in pixels.
|
||||
*/
|
||||
abstract public static function get_max_dimension();
|
||||
|
||||
/**
|
||||
* Initialize the cache handler.
|
||||
*
|
||||
* Subclasses should override this to register filters and actions.
|
||||
*/
|
||||
public static function init() {
|
||||
// Subclasses implement specific initialization.
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this cache type is enabled.
|
||||
*
|
||||
* @return bool True if enabled, false otherwise.
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
$type = static::get_type();
|
||||
|
||||
/**
|
||||
* Filters whether a specific cache type is enabled.
|
||||
*
|
||||
* The dynamic portion of the hook name, `$type`, refers to the cache type
|
||||
* (e.g., 'avatar', 'media', 'emoji').
|
||||
*
|
||||
* @since 5.6.0
|
||||
*
|
||||
* @param bool $enabled Whether this cache type is enabled. Default true.
|
||||
*/
|
||||
return (bool) \apply_filters( "activitypub_cache_{$type}_enabled", true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage paths for an entity.
|
||||
*
|
||||
* @param string|int $entity_id The entity identifier (post ID, domain, etc.).
|
||||
*
|
||||
* @return array {
|
||||
* Storage paths for the entity.
|
||||
*
|
||||
* @type string $basedir Base directory path.
|
||||
* @type string $baseurl Base URL.
|
||||
* }
|
||||
*/
|
||||
public static function get_storage_paths( $entity_id ) {
|
||||
$upload_dir = \wp_upload_dir();
|
||||
$entity_id = \sanitize_file_name( (string) $entity_id );
|
||||
|
||||
return array(
|
||||
'basedir' => $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;
|
||||
}
|
||||
}
|
||||
191
wp-content/plugins/activitypub/includes/cache/class-media.php
vendored
Normal file
191
wp-content/plugins/activitypub/includes/cache/class-media.php
vendored
Normal file
@ -0,0 +1,191 @@
|
||||
<?php
|
||||
/**
|
||||
* Media cache class.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\Cache;
|
||||
|
||||
use Activitypub\Collection\Remote_Posts;
|
||||
|
||||
/**
|
||||
* Media cache class.
|
||||
*
|
||||
* Handles lazy caching of remote post and comment media locally.
|
||||
* Media is cached on-demand when URLs pass through the `activitypub_remote_media_url` filter.
|
||||
*
|
||||
* Storage locations:
|
||||
* - Posts: /wp-content/uploads/activitypub/posts/{post_id}/
|
||||
* - Comments: /wp-content/uploads/activitypub/comments/{comment_id}/
|
||||
*
|
||||
* Files are cleaned up automatically when the parent post is deleted.
|
||||
*
|
||||
* @since 5.6.0
|
||||
*/
|
||||
class Media extends File {
|
||||
/**
|
||||
* Maximum dimension for media images in pixels.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_DIMENSION = 1200;
|
||||
|
||||
/**
|
||||
* Context identifier for post media.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONTEXT = 'media';
|
||||
|
||||
/**
|
||||
* Context identifier for comment media.
|
||||
*
|
||||
* Reserved for future use when comment media caching is implemented.
|
||||
* Currently, only post media caching is active via maybe_cache().
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONTEXT_COMMENT = 'comment_media';
|
||||
|
||||
/**
|
||||
* Base directory for post media.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BASE_DIR_POSTS = '/activitypub/posts/';
|
||||
|
||||
/**
|
||||
* Base directory for comment media.
|
||||
*
|
||||
* Reserved for future use when comment media caching is implemented.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const BASE_DIR_COMMENTS = '/activitypub/comments/';
|
||||
|
||||
/**
|
||||
* Get the cache type identifier.
|
||||
*
|
||||
* @return string Cache type.
|
||||
*/
|
||||
public static function get_type() {
|
||||
return 'media';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory path relative to uploads.
|
||||
*
|
||||
* Default to post media directory. Use get_storage_paths_for_context()
|
||||
* for context-aware path resolution.
|
||||
*
|
||||
* @return string Base directory path.
|
||||
*/
|
||||
public static function get_base_dir() {
|
||||
return self::BASE_DIR_POSTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context identifier for the filter.
|
||||
*
|
||||
* @return string Context identifier.
|
||||
*/
|
||||
public static function get_context() {
|
||||
return self::CONTEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum dimension for media images.
|
||||
*
|
||||
* @return int Maximum width/height in pixels.
|
||||
*/
|
||||
public static function get_max_dimension() {
|
||||
return self::MAX_DIMENSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage paths based on context.
|
||||
*
|
||||
* @param string|int $entity_id The entity identifier.
|
||||
* @param string $context The context ('media' or 'comment_media').
|
||||
*
|
||||
* @return array {
|
||||
* Storage paths for the entity.
|
||||
*
|
||||
* @type string $basedir Base directory path.
|
||||
* @type string $baseurl Base URL.
|
||||
* }
|
||||
*/
|
||||
public static function get_storage_paths_for_context( $entity_id, $context = self::CONTEXT ) {
|
||||
$upload_dir = \wp_upload_dir();
|
||||
$entity_id = \sanitize_file_name( (string) $entity_id );
|
||||
$base_dir = self::CONTEXT_COMMENT === $context ? self::BASE_DIR_COMMENTS : self::BASE_DIR_POSTS;
|
||||
|
||||
return array(
|
||||
'basedir' => $upload_dir['basedir'] . $base_dir . $entity_id,
|
||||
'baseurl' => $upload_dir['baseurl'] . $base_dir . $entity_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cache handler.
|
||||
*/
|
||||
public static function init() {
|
||||
// Only register local caching filter when caching is enabled.
|
||||
if ( self::is_enabled() ) {
|
||||
\add_filter( 'activitypub_remote_media_url', array( self::class, 'maybe_cache' ), 10, 4 );
|
||||
|
||||
// Clean up when post is deleted.
|
||||
\add_action( 'before_delete_post', array( self::class, 'maybe_cleanup' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe cache a media URL.
|
||||
*
|
||||
* Hooked to the activitypub_remote_media_url filter.
|
||||
* Downloads and caches the file locally if not already cached.
|
||||
*
|
||||
* @param string $url The remote URL.
|
||||
* @param string $context The context ('avatar', 'media', 'emoji', etc.).
|
||||
* @param string|int $entity_id The entity identifier (post ID).
|
||||
* @param array $options Optional. Additional options.
|
||||
*
|
||||
* @return string The local URL if cached successfully, otherwise the original URL.
|
||||
*/
|
||||
public static function maybe_cache( $url, $context, $entity_id = null, $options = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required for filter signature.
|
||||
if ( self::CONTEXT !== $context || empty( $url ) || empty( $entity_id ) ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$cached_url = self::get_or_cache( $url, $entity_id );
|
||||
|
||||
return $cached_url ?: $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe clean up cached media when post is deleted.
|
||||
*
|
||||
* @param int $post_id The post ID being deleted.
|
||||
*/
|
||||
public static function maybe_cleanup( $post_id ) {
|
||||
if ( Remote_Posts::POST_TYPE !== \get_post_type( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::invalidate_entity( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached media for a comment.
|
||||
*
|
||||
* @param int $comment_id The comment ID.
|
||||
*
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public static function invalidate_comment( $comment_id ) {
|
||||
$paths = self::get_storage_paths_for_context( $comment_id, self::CONTEXT_COMMENT );
|
||||
|
||||
return static::delete_directory( $paths['basedir'] );
|
||||
}
|
||||
}
|
||||
684
wp-content/plugins/activitypub/includes/cache/class-stats-image.php
vendored
Normal file
684
wp-content/plugins/activitypub/includes/cache/class-stats-image.php
vendored
Normal file
@ -0,0 +1,684 @@
|
||||
<?php
|
||||
/**
|
||||
* Stats Image cache class.
|
||||
*
|
||||
* @package Activitypub
|
||||
* @since 8.1.0
|
||||
*/
|
||||
|
||||
namespace Activitypub\Cache;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
use Activitypub\Model\Application;
|
||||
use Activitypub\Model\Blog;
|
||||
use Activitypub\Statistics;
|
||||
|
||||
/**
|
||||
* Stats Image cache class.
|
||||
*
|
||||
* Generates, caches, and serves shareable stats images.
|
||||
* Extends the File cache base class for storage, optimization, and cleanup.
|
||||
* Images are stored in /wp-content/uploads/activitypub/stats/{user_id}/
|
||||
*/
|
||||
class Stats_Image extends File {
|
||||
|
||||
/**
|
||||
* Image width in pixels.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const WIDTH = 1200;
|
||||
|
||||
/**
|
||||
* Image height in pixels.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const HEIGHT = 630;
|
||||
|
||||
/**
|
||||
* Get the cache type identifier.
|
||||
*
|
||||
* @return string Cache type.
|
||||
*/
|
||||
public static function get_type() {
|
||||
return 'stats_image';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base directory path relative to uploads.
|
||||
*
|
||||
* @return string Base directory path.
|
||||
*/
|
||||
public static function get_base_dir() {
|
||||
return '/activitypub/stats/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context identifier for the filter.
|
||||
*
|
||||
* @return string Context identifier.
|
||||
*/
|
||||
public static function get_context() {
|
||||
return 'stats_image';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum dimension for images of this type.
|
||||
*
|
||||
* Stats images have a fixed size, so no resizing is needed.
|
||||
*
|
||||
* @return int Maximum width/height in pixels.
|
||||
*/
|
||||
public static function get_max_dimension() {
|
||||
return self::WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the GD library is available.
|
||||
*
|
||||
* @return bool Whether GD is available.
|
||||
*/
|
||||
public static function is_available() {
|
||||
return \function_exists( 'imagecreatetruecolor' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for a stats image, generating it if needed.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
*
|
||||
* @return string|\WP_Error The public URL or error.
|
||||
*/
|
||||
public static function get_url( $user_id, $year ) {
|
||||
if ( ! self::is_available() ) {
|
||||
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
|
||||
}
|
||||
|
||||
// If local caching is disabled, use the REST endpoint for on-the-fly generation.
|
||||
if ( ! static::is_enabled() ) {
|
||||
$url = \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE . '/stats/image/' . $user_id . '/' . $year );
|
||||
|
||||
/**
|
||||
* Filters the stats image URL.
|
||||
*
|
||||
* Can be used to route through a CDN or image proxy like Photon.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param string $url The image URL.
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
*/
|
||||
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
|
||||
}
|
||||
|
||||
$hash = self::get_hash( $user_id, $year );
|
||||
$paths = static::get_storage_paths( $user_id );
|
||||
|
||||
// Check for cached file using the base class glob pattern.
|
||||
$pattern = static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' . $hash ) . '.*';
|
||||
$matches = \glob( $pattern );
|
||||
|
||||
if ( ! empty( $matches ) && \is_file( $matches[0] ) ) {
|
||||
$url = $paths['baseurl'] . '/' . \basename( $matches[0] );
|
||||
|
||||
/** This filter is documented in includes/cache/class-stats-image.php */
|
||||
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
|
||||
}
|
||||
|
||||
// Generate the image.
|
||||
$result = self::generate( $user_id, $year );
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$url = $paths['baseurl'] . '/' . \basename( $result );
|
||||
|
||||
/** This filter is documented in includes/cache/class-stats-image.php */
|
||||
return \apply_filters( 'activitypub_stats_image_url', $url, $user_id, $year );
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a stats image, generating it if needed.
|
||||
*
|
||||
* Outputs headers and image data, then exits.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
*
|
||||
* @return \WP_Error|void Error on failure, exits on success.
|
||||
*/
|
||||
public static function serve( $user_id, $year ) {
|
||||
if ( ! self::is_available() ) {
|
||||
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
|
||||
}
|
||||
|
||||
$hash = self::get_hash( $user_id, $year );
|
||||
$paths = static::get_storage_paths( $user_id );
|
||||
|
||||
// Check for cached file.
|
||||
$pattern = static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' . $hash ) . '.*';
|
||||
$matches = \glob( $pattern );
|
||||
$file = ( ! empty( $matches ) && \is_file( $matches[0] ) ) ? $matches[0] : null;
|
||||
|
||||
if ( ! $file ) {
|
||||
$file = self::generate( $user_id, $year );
|
||||
}
|
||||
|
||||
if ( \is_wp_error( $file ) ) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$mime_type = static::get_file_mime_type( $file );
|
||||
|
||||
\header( 'Content-Type: ' . ( $mime_type ?: 'image/png' ) );
|
||||
\header( 'Content-Length: ' . \filesize( $file ) );
|
||||
\header( 'Cache-Control: public, max-age=86400' );
|
||||
\header( 'X-Content-Type-Options: nosniff' );
|
||||
|
||||
\readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the stats image and save to cache.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
*
|
||||
* @return string|\WP_Error Cached file path or error.
|
||||
*/
|
||||
public static function generate( $user_id, $year ) {
|
||||
if ( ! self::is_available() ) {
|
||||
return new \WP_Error( 'gd_not_available', \__( 'GD library is not available.', 'activitypub' ), array( 'status' => 501 ) );
|
||||
}
|
||||
|
||||
$summary = Statistics::get_annual_summary( $user_id, $year );
|
||||
|
||||
if ( ! $summary ) {
|
||||
$summary = Statistics::compile_annual_summary( $user_id, $year );
|
||||
}
|
||||
|
||||
if ( ! $summary || empty( $summary['posts_count'] ) ) {
|
||||
return new \WP_Error( 'no_stats', \__( 'No statistics available for this period.', 'activitypub' ), array( 'status' => 404 ) );
|
||||
}
|
||||
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
if ( Actors::BLOG_USER_ID === $user_id ) {
|
||||
$actor = new Blog();
|
||||
} elseif ( Actors::APPLICATION_USER_ID === $user_id ) {
|
||||
$actor = new Application();
|
||||
}
|
||||
}
|
||||
|
||||
$actor_webfinger = ! \is_wp_error( $actor ) ? $actor->get_webfinger() : '';
|
||||
$site_name = \get_bloginfo( 'name' );
|
||||
|
||||
if ( ! \function_exists( 'wp_tempnam' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
}
|
||||
|
||||
$tmp_file = self::render( $summary, $actor_webfinger, $site_name, $year );
|
||||
|
||||
if ( \is_wp_error( $tmp_file ) ) {
|
||||
return $tmp_file;
|
||||
}
|
||||
|
||||
// Use the base class storage paths and optimization.
|
||||
$paths = static::get_storage_paths( $user_id );
|
||||
|
||||
if ( ! \wp_mkdir_p( $paths['basedir'] ) ) {
|
||||
\wp_delete_file( $tmp_file );
|
||||
return new \WP_Error( 'cache_dir_failed', \__( 'Failed to create cache directory.', 'activitypub' ), array( 'status' => 500 ) );
|
||||
}
|
||||
|
||||
// Remove old cached images for this year before saving the new one.
|
||||
$old_files = \glob( static::escape_glob_pattern( $paths['basedir'] . '/stats-' . $year . '-' ) . '*.*' );
|
||||
if ( $old_files ) {
|
||||
foreach ( $old_files as $old_file ) {
|
||||
\wp_delete_file( $old_file );
|
||||
}
|
||||
}
|
||||
|
||||
$hash = self::get_hash( $user_id, $year );
|
||||
$dest_name = \sprintf( 'stats-%d-%s.png', $year, $hash );
|
||||
$dest_path = $paths['basedir'] . '/' . $dest_name;
|
||||
|
||||
static::get_filesystem()->move( $tmp_file, $dest_path, true );
|
||||
|
||||
// Keep as PNG for maximum compatibility when sharing on social networks.
|
||||
return $dest_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash for cache invalidation.
|
||||
*
|
||||
* Includes the theme stylesheet, version, and stats compilation
|
||||
* timestamp so cached images are regenerated when the theme or
|
||||
* the underlying stats data changes.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param int $year The year.
|
||||
*
|
||||
* @return string The hash string.
|
||||
*/
|
||||
private static function get_hash( $user_id = 0, $year = 0 ) {
|
||||
$parts = array(
|
||||
\get_stylesheet(),
|
||||
\wp_get_theme()->get( 'Version' ),
|
||||
);
|
||||
|
||||
if ( $user_id && $year ) {
|
||||
$summary = Statistics::get_annual_summary( $user_id, $year );
|
||||
|
||||
if ( $summary && ! empty( $summary['compiled_at'] ) ) {
|
||||
$parts[] = $summary['compiled_at'];
|
||||
}
|
||||
}
|
||||
|
||||
return \md5( \wp_json_encode( $parts ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stats image as a temporary PNG file.
|
||||
*
|
||||
* @param array $summary The annual stats summary.
|
||||
* @param string $actor_webfinger The actor webfinger identifier.
|
||||
* @param string $site_name The site name.
|
||||
* @param int $year The year.
|
||||
* @return string|\WP_Error Path to temporary PNG file or error.
|
||||
*/
|
||||
private static function render( $summary, $actor_webfinger, $site_name, $year ) {
|
||||
$width = self::WIDTH;
|
||||
$height = self::HEIGHT;
|
||||
|
||||
$image = \imagecreatetruecolor( $width, $height );
|
||||
|
||||
if ( ! $image ) {
|
||||
return new \WP_Error( 'image_create_failed', \__( 'Failed to create image.', 'activitypub' ), array( 'status' => 500 ) );
|
||||
}
|
||||
|
||||
\imageantialias( $image, true );
|
||||
|
||||
$colors = self::resolve_colors();
|
||||
$bg = \imagecolorallocate( $image, $colors['bg'][0], $colors['bg'][1], $colors['bg'][2] );
|
||||
$fg = \imagecolorallocate( $image, $colors['fg'][0], $colors['fg'][1], $colors['fg'][2] );
|
||||
$muted = \imagecolorallocate( $image, $colors['muted'][0], $colors['muted'][1], $colors['muted'][2] );
|
||||
|
||||
\imagefill( $image, 0, 0, $bg );
|
||||
|
||||
$font = self::resolve_font();
|
||||
|
||||
// Total engagement.
|
||||
$comment_types = Statistics::get_comment_types_for_stats();
|
||||
$total_engagement = 0;
|
||||
foreach ( \array_keys( $comment_types ) as $slug ) {
|
||||
$total_engagement += $summary[ $slug . '_count' ] ?? 0;
|
||||
}
|
||||
|
||||
// Title.
|
||||
$title = \sprintf(
|
||||
/* translators: %d: The year */
|
||||
\__( 'Fediverse Stats %d', 'activitypub' ),
|
||||
$year
|
||||
);
|
||||
self::draw_text( $image, $title, null, 100, 36, $fg, $font );
|
||||
|
||||
// Actor webfinger.
|
||||
if ( $actor_webfinger ) {
|
||||
self::draw_text( $image, $actor_webfinger, null, 150, 20, $muted, $font );
|
||||
}
|
||||
|
||||
// Three big stats in a row.
|
||||
$stats = array(
|
||||
array(
|
||||
'value' => \number_format_i18n( $summary['posts_count'] ),
|
||||
'label' => \__( 'Posts', 'activitypub' ),
|
||||
),
|
||||
array(
|
||||
'value' => \number_format_i18n( $total_engagement ),
|
||||
'label' => \__( 'Engagements', 'activitypub' ),
|
||||
),
|
||||
array(
|
||||
'value' => \number_format_i18n( $summary['followers_end'] ?? 0 ),
|
||||
'label' => \__( 'Followers', 'activitypub' ),
|
||||
),
|
||||
);
|
||||
|
||||
$col_width = (int) ( $width / 3 );
|
||||
|
||||
foreach ( $stats as $i => $stat ) {
|
||||
$center_x = (int) ( $col_width * $i + $col_width / 2 );
|
||||
self::draw_text( $image, $stat['value'], $center_x, 300, 56, $fg, $font );
|
||||
self::draw_text( $image, $stat['label'], $center_x, 355, 18, $muted, $font );
|
||||
}
|
||||
|
||||
// Follower growth line.
|
||||
$followers_net = $summary['followers_net_change'] ?? 0;
|
||||
$change_sign = $followers_net >= 0 ? '+' : '';
|
||||
$growth_text = \sprintf(
|
||||
/* translators: %s: follower net change */
|
||||
\__( '%s followers this year', 'activitypub' ),
|
||||
$change_sign . \number_format_i18n( $followers_net )
|
||||
);
|
||||
self::draw_text( $image, $growth_text, null, 450, 20, $muted, $font );
|
||||
|
||||
// Branding.
|
||||
$branding = $site_name . ' - ' . \__( 'Powered by ActivityPub', 'activitypub' );
|
||||
self::draw_text( $image, $branding, null, $height - 40, 14, $muted, $font );
|
||||
|
||||
// Save to temp file.
|
||||
$tmp_file = \wp_tempnam( 'activitypub-stats-' );
|
||||
|
||||
if ( ! $tmp_file ) {
|
||||
return new \WP_Error( 'temp_file_failed', \__( 'Could not create temporary file.', 'activitypub' ), array( 'status' => 500 ) );
|
||||
}
|
||||
|
||||
$saved = \imagepng( $image, $tmp_file );
|
||||
|
||||
// imagedestroy() is deprecated since PHP 8.5 and a no-op since 8.0.
|
||||
if ( \PHP_VERSION_ID < 80000 ) {
|
||||
\imagedestroy( $image );
|
||||
}
|
||||
|
||||
if ( ! $saved ) {
|
||||
\wp_delete_file( $tmp_file );
|
||||
return new \WP_Error( 'image_write_failed', \__( 'Failed to write stats image.', 'activitypub' ), array( 'status' => 500 ) );
|
||||
}
|
||||
|
||||
return $tmp_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw text on the image, centered on the canvas or at a specific x position.
|
||||
*
|
||||
* Uses TrueType rendering when a font is available, falls back to
|
||||
* GD built-in fonts.
|
||||
*
|
||||
* @param resource $image The image resource.
|
||||
* @param string $text The text to draw.
|
||||
* @param int|null $x The center x position, or null to center on canvas.
|
||||
* @param int $y The y position.
|
||||
* @param int|float $size Font size in points (TTF) or 1-5 (built-in).
|
||||
* @param int $color The text color.
|
||||
* @param string|false $font Path to TTF file, or false for built-in.
|
||||
*/
|
||||
private static function draw_text( $image, $text, $x, $y, $size, $color, $font = false ) {
|
||||
if ( $font && \function_exists( 'imagefttext' ) ) {
|
||||
$bbox = \imageftbbox( $size, 0, $font, $text );
|
||||
$text_width = $bbox[2] - $bbox[0];
|
||||
$draw_x = null === $x
|
||||
? (int) ( ( self::WIDTH - $text_width ) / 2 )
|
||||
: (int) ( $x - $text_width / 2 );
|
||||
\imagefttext( $image, $size, 0, $draw_x, $y, $color, $font, $text );
|
||||
} else {
|
||||
$builtin_size = \min( 5, \max( 1, (int) ( $size / 10 ) ) );
|
||||
$font_width = \imagefontwidth( $builtin_size );
|
||||
$text_width = $font_width * \strlen( $text );
|
||||
$draw_x = null === $x
|
||||
? (int) ( ( self::WIDTH - $text_width ) / 2 )
|
||||
: (int) ( $x - $text_width / 2 );
|
||||
\imagestring( $image, $builtin_size, $draw_x, $y, $text, $color );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve colors from theme Global Styles or overrides.
|
||||
*
|
||||
* @return array Associative array with 'bg', 'fg', and 'muted' RGB arrays.
|
||||
*/
|
||||
private static function resolve_colors() {
|
||||
$bg_rgb = array( 255, 255, 255 );
|
||||
$fg_rgb = array( 17, 17, 17 );
|
||||
|
||||
$palette = array();
|
||||
$settings = \wp_get_global_settings();
|
||||
if ( ! empty( $settings['color']['palette'] ) ) {
|
||||
foreach ( $settings['color']['palette'] as $colors ) {
|
||||
foreach ( $colors as $color ) {
|
||||
$palette[ $color['slug'] ] = $color['color'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$styles = \wp_get_global_styles( array( 'color' ) );
|
||||
$bg_resolved = self::resolve_style_color( $styles['background'] ?? '', $palette );
|
||||
$fg_resolved = self::resolve_style_color( $styles['text'] ?? '', $palette );
|
||||
|
||||
if ( $bg_resolved ) {
|
||||
$bg_rgb = $bg_resolved;
|
||||
}
|
||||
|
||||
if ( $fg_resolved ) {
|
||||
$fg_rgb = $fg_resolved;
|
||||
}
|
||||
|
||||
if ( ! $bg_resolved || ! $fg_resolved ) {
|
||||
$bg_slugs = array( 'base', 'background', 'white' );
|
||||
$fg_slugs = array( 'contrast', 'foreground', 'black', 'dark-gray' );
|
||||
|
||||
if ( ! $bg_resolved ) {
|
||||
foreach ( $bg_slugs as $slug ) {
|
||||
if ( ! empty( $palette[ $slug ] ) ) {
|
||||
$parsed = self::parse_hex( $palette[ $slug ] );
|
||||
if ( $parsed ) {
|
||||
$bg_rgb = $parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $fg_resolved ) {
|
||||
foreach ( $fg_slugs as $slug ) {
|
||||
if ( ! empty( $palette[ $slug ] ) ) {
|
||||
$parsed = self::parse_hex( $palette[ $slug ] );
|
||||
if ( $parsed ) {
|
||||
$fg_rgb = $parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::build_color_set( $bg_rgb, $fg_rgb );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a color set with a derived muted color.
|
||||
*
|
||||
* @param array $bg_rgb Background RGB.
|
||||
* @param array $fg_rgb Foreground RGB.
|
||||
*
|
||||
* @return array { bg, fg, muted } RGB arrays.
|
||||
*/
|
||||
private static function build_color_set( $bg_rgb, $fg_rgb ) {
|
||||
return array(
|
||||
'bg' => $bg_rgb,
|
||||
'fg' => $fg_rgb,
|
||||
'muted' => array(
|
||||
(int) ( ( $fg_rgb[0] + $bg_rgb[0] ) / 2 ),
|
||||
(int) ( ( $fg_rgb[1] + $bg_rgb[1] ) / 2 ),
|
||||
(int) ( ( $fg_rgb[2] + $bg_rgb[2] ) / 2 ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a color value from Global Styles.
|
||||
*
|
||||
* @param string $value The color value (hex or CSS variable).
|
||||
* @param array $palette The merged color palette (slug => hex).
|
||||
*
|
||||
* @return array|false RGB array or false.
|
||||
*/
|
||||
private static function resolve_style_color( $value, $palette ) {
|
||||
if ( empty( $value ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( '#' === $value[0] ) {
|
||||
return self::parse_hex( $value );
|
||||
}
|
||||
|
||||
if ( \preg_match( '/--color--([a-z0-9-]+)/', $value, $matches ) ) {
|
||||
if ( ! empty( $palette[ $matches[1] ] ) ) {
|
||||
return self::parse_hex( $palette[ $matches[1] ] );
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a hex color string into an RGB array.
|
||||
*
|
||||
* @param string $hex The hex color (e.g. '#FF0000' or '#F00').
|
||||
*
|
||||
* @return array|false Array of [r, g, b] or false on failure.
|
||||
*/
|
||||
private static function parse_hex( $hex ) {
|
||||
$hex = \ltrim( $hex, '#' );
|
||||
|
||||
if ( 3 === \strlen( $hex ) ) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
|
||||
if ( 6 !== \strlen( $hex ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = \sscanf( $hex, '%02x%02x%02x' );
|
||||
|
||||
return ( 3 === \count( $result ) ) ? $result : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a TTF font file from the active theme or Font Library.
|
||||
*
|
||||
* @return string|false Path to a TTF file, or false if none found.
|
||||
*/
|
||||
private static function resolve_font() {
|
||||
$body_slug = '';
|
||||
$styles = \wp_get_global_styles( array( 'typography' ) );
|
||||
if ( ! empty( $styles['fontFamily'] ) && \preg_match( '/--font-family--([a-z0-9-]+)/', $styles['fontFamily'], $matches ) ) {
|
||||
$body_slug = $matches[1];
|
||||
}
|
||||
|
||||
$settings = \wp_get_global_settings();
|
||||
if ( ! empty( $settings['typography']['fontFamilies'] ) ) {
|
||||
$all_families = array();
|
||||
foreach ( $settings['typography']['fontFamilies'] as $families ) {
|
||||
foreach ( $families as $family ) {
|
||||
$all_families[] = $family;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort so the body font family is tried first.
|
||||
if ( $body_slug ) {
|
||||
\usort(
|
||||
$all_families,
|
||||
function ( $a, $b ) use ( $body_slug ) {
|
||||
return ( ( $a['slug'] ?? '' ) === $body_slug ? 0 : 1 ) - ( ( $b['slug'] ?? '' ) === $body_slug ? 0 : 1 );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
$font = self::find_ttf_in_families( $all_families );
|
||||
if ( $font ) {
|
||||
return $font;
|
||||
}
|
||||
}
|
||||
|
||||
// Try the Font Library (WP 6.5+).
|
||||
$font = self::find_ttf_in_font_library();
|
||||
if ( $font ) {
|
||||
return $font;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a TTF/OTF file in font family definitions.
|
||||
*
|
||||
* @param array $families The font families to search.
|
||||
*
|
||||
* @return string|false Path to TTF file or false.
|
||||
*/
|
||||
private static function find_ttf_in_families( $families ) {
|
||||
$theme_dir = \get_theme_root();
|
||||
|
||||
foreach ( $families as $family ) {
|
||||
if ( empty( $family['fontFace'] ) ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( $family['fontFace'] as $face ) {
|
||||
$src = \is_array( $face['src'] ) ? $face['src'][0] : $face['src'];
|
||||
|
||||
if ( ! \preg_match( '/\.(ttf|otf)$/i', $src ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve theme-relative paths.
|
||||
if ( 0 === \strpos( $src, 'file:./' ) ) {
|
||||
$src = \get_theme_file_path( \substr( $src, 7 ) );
|
||||
}
|
||||
|
||||
// Only allow fonts within the themes directory for security.
|
||||
$real_path = \realpath( $src );
|
||||
if ( ! $real_path || 0 !== \strpos( $real_path, \realpath( $theme_dir ) ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $real_path;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a TTF/OTF file from the WordPress Font Library.
|
||||
*
|
||||
* @return string|false Path to TTF file or false.
|
||||
*/
|
||||
private static function find_ttf_in_font_library() {
|
||||
$font_families = \get_posts(
|
||||
array(
|
||||
'post_type' => 'wp_font_family',
|
||||
'posts_per_page' => 10,
|
||||
'post_status' => 'publish',
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $font_families as $font_family ) {
|
||||
$faces = \get_posts(
|
||||
array(
|
||||
'post_type' => 'wp_font_face',
|
||||
'post_parent' => $font_family->ID,
|
||||
'posts_per_page' => 10,
|
||||
'post_status' => 'publish',
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $faces as $face ) {
|
||||
$file = \get_post_meta( $face->ID, '_wp_font_face_file', true );
|
||||
if ( $file && \preg_match( '/\.(ttf|otf)$/i', $file ) ) {
|
||||
$path = \path_join( \wp_get_font_dir()['path'], $file );
|
||||
if ( \file_exists( $path ) ) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user