PluginProbe
ActivityPub / 9.2.1
ActivityPub v9.2.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / cache / class-file.php

class-file.php in ActivityPub 9.2.1, at includes/cache/class-file.php

672 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File cache abstract class.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\Cache;
9
10 /**
11 * Abstract file cache class.
12 *
13 * Provides shared functionality for caching remote media files locally.
14 * Subclasses implement type-specific storage paths and initialization.
15 *
16 * Caching is lazy/filter-based: URLs pass through the `activitypub_remote_media_url`
17 * filter, and cache handlers check if already cached or download on demand.
18 *
19 * @since 5.6.0
20 */
21 abstract class File {
22 /**
23 * Maximum file size in bytes (10MB).
24 *
25 * @var int
26 */
27 const MAX_FILE_SIZE = 10485760; // 10 * 1024 * 1024
28
29 /**
30 * Default allowed MIME types for cached files.
31 *
32 * @var array
33 */
34 const DEFAULT_ALLOWED_MIME_TYPES = array(
35 'image/jpeg',
36 'image/png',
37 'image/gif',
38 'image/webp',
39 );
40
41 /**
42 * Cached finfo instance for MIME type detection.
43 *
44 * Using a cached instance avoids repeated finfo_open() calls and
45 * the deprecated finfo_close() in PHP 8.5+.
46 *
47 * @var \finfo|null
48 */
49 private static $finfo = null;
50
51
52 /**
53 * Get the cache type identifier.
54 *
55 * @return string Cache type (e.g., 'avatar', 'media', 'emoji').
56 */
57 abstract public static function get_type();
58
59 /**
60 * Get the base directory path relative to uploads.
61 *
62 * @return string Base directory path (e.g., '/activitypub/actors/').
63 */
64 abstract public static function get_base_dir();
65
66 /**
67 * Get the context identifier for the activitypub_remote_media_url filter.
68 *
69 * @return string Context identifier (e.g., 'avatar', 'media', 'emoji').
70 */
71 abstract public static function get_context();
72
73 /**
74 * Get the maximum dimension for images of this type.
75 *
76 * @return int Maximum width/height in pixels.
77 */
78 abstract public static function get_max_dimension();
79
80 /**
81 * Initialize the cache handler.
82 *
83 * Subclasses should override this to register filters and actions.
84 */
85 public static function init() {
86 // Subclasses implement specific initialization.
87 }
88
89 /**
90 * Check if this cache type is enabled.
91 *
92 * @return bool True if enabled, false otherwise.
93 */
94 public static function is_enabled() {
95 $type = static::get_type();
96
97 /**
98 * Filters whether a specific cache type is enabled.
99 *
100 * The dynamic portion of the hook name, `$type`, refers to the cache type
101 * (e.g., 'avatar', 'media', 'emoji').
102 *
103 * @since 5.6.0
104 *
105 * @param bool $enabled Whether this cache type is enabled. Default true.
106 */
107 return (bool) \apply_filters( "activitypub_cache_{$type}_enabled", true );
108 }
109
110 /**
111 * Get storage paths for an entity.
112 *
113 * @param string|int $entity_id The entity identifier (post ID, domain, etc.).
114 *
115 * @return array {
116 * Storage paths for the entity.
117 *
118 * @type string $basedir Base directory path.
119 * @type string $baseurl Base URL.
120 * }
121 */
122 public static function get_storage_paths( $entity_id ) {
123 $upload_dir = \wp_upload_dir();
124 $entity_id = \sanitize_file_name( (string) $entity_id );
125
126 return array(
127 'basedir' => $upload_dir['basedir'] . static::get_base_dir() . $entity_id,
128 'baseurl' => $upload_dir['baseurl'] . static::get_base_dir() . $entity_id,
129 );
130 }
131
132 /**
133 * Get a cached file URL if it exists.
134 *
135 * @param string $url The remote URL.
136 * @param string|int $entity_id The entity identifier.
137 *
138 * @return string|false The local URL if cached, false otherwise.
139 */
140 public static function get( $url, $entity_id ) {
141 if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
142 return false;
143 }
144
145 $paths = static::get_storage_paths( $entity_id );
146
147 if ( ! \is_dir( $paths['basedir'] ) ) {
148 return false;
149 }
150
151 $hash = static::generate_hash( $url );
152 $pattern = static::escape_glob_pattern( $paths['basedir'] . '/' . $hash ) . '.*';
153 $matches = \glob( $pattern );
154
155 if ( ! empty( $matches ) && \is_file( $matches[0] ) ) {
156 return $paths['baseurl'] . '/' . \basename( $matches[0] );
157 }
158
159 return false;
160 }
161
162 /**
163 * Get a cached file or cache it if not present.
164 *
165 * This is the main entry point for lazy caching. Called via filter hooks.
166 *
167 * @param string $url The remote URL.
168 * @param string|int $entity_id The entity identifier.
169 * @param array $options Optional. Additional options like 'updated' timestamp.
170 *
171 * @return string|false The local URL on success, false on failure.
172 */
173 public static function get_or_cache( $url, $entity_id, $options = array() ) {
174 if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
175 return false;
176 }
177
178 // Check if already cached.
179 $cached_url = static::get( $url, $entity_id );
180 if ( $cached_url ) {
181 // Check for staleness if updated timestamp provided.
182 if ( ! empty( $options['updated'] ) ) {
183 $paths = static::get_storage_paths( $entity_id );
184 $hash = static::generate_hash( $url );
185 $pattern = static::escape_glob_pattern( $paths['basedir'] . '/' . $hash ) . '.*';
186 $matches = \glob( $pattern );
187 $file_path = ( $matches && \is_file( $matches[0] ) ) ? $matches[0] : null;
188 $local_time = $file_path ? \filemtime( $file_path ) : 0;
189 $remote_time = \strtotime( $options['updated'] );
190
191 if ( $remote_time && $local_time >= $remote_time ) {
192 return $cached_url;
193 }
194 // Stale - continue to re-download.
195 } else {
196 return $cached_url;
197 }
198 }
199
200 // Download and cache the file.
201 return static::cache( $url, $entity_id, $options );
202 }
203
204 /**
205 * Cache a remote file locally.
206 *
207 * Downloads the file, validates it, optimizes images, and stores locally.
208 *
209 * @param string $url The remote URL.
210 * @param string|int $entity_id The entity identifier.
211 * @param array $options Optional. Additional options.
212 *
213 * @return string|false The local URL on success, false on failure.
214 */
215 public static function cache( $url, $entity_id, $options = array() ) {
216 $result = static::download_and_validate( $url );
217
218 if ( \is_wp_error( $result ) || empty( $result['file'] ) ) {
219 return false;
220 }
221
222 $tmp_file = $result['file'];
223 $paths = static::get_storage_paths( $entity_id );
224
225 // Create directory if it doesn't exist.
226 if ( ! \wp_mkdir_p( $paths['basedir'] ) ) {
227 \wp_delete_file( $tmp_file );
228 return false;
229 }
230
231 // Generate hash-based filename.
232 $hash = static::generate_hash( $url );
233 $ext = \pathinfo( $tmp_file, PATHINFO_EXTENSION );
234 if ( empty( $ext ) ) {
235 $ext = \wp_get_default_extension_for_mime_type( $result['mime_type'] );
236 }
237 $file_name = $hash . '.' . $ext;
238 $file_path = $paths['basedir'] . '/' . $file_name;
239
240 // Move file to destination.
241 if ( ! static::get_filesystem()->move( $tmp_file, $file_path, true ) ) {
242 \wp_delete_file( $tmp_file );
243 return false;
244 }
245
246 // Optimize image if applicable.
247 $max_dimension = $options['max_dimension'] ?? static::get_max_dimension();
248 $file_path = static::optimize_image( $file_path, $max_dimension );
249 $file_name = \basename( $file_path );
250
251 $local_url = $paths['baseurl'] . '/' . $file_name;
252
253 /**
254 * Fires after a remote media file has been successfully cached.
255 *
256 * Use this hook for logging, analytics, or post-processing.
257 *
258 * @since 5.6.0
259 *
260 * @param string $local_url The local URL of the cached file.
261 * @param string $url The original remote URL.
262 * @param string|int $entity_id The entity identifier.
263 * @param string $type The cache type ('avatar', 'media', 'emoji').
264 * @param string $file_path The local file system path.
265 */
266 \do_action( 'activitypub_media_cached', $local_url, $url, $entity_id, static::get_type(), $file_path );
267
268 return $local_url;
269 }
270
271 /**
272 * Invalidate cached files for an entity.
273 *
274 * Deletes the entire entity directory and all its contents.
275 *
276 * @param string|int $entity_id The entity identifier.
277 *
278 * @return bool True on success, false on failure.
279 */
280 public static function invalidate_entity( $entity_id ) {
281 $paths = static::get_storage_paths( $entity_id );
282
283 return static::delete_directory( $paths['basedir'] );
284 }
285
286 /**
287 * Get a direct filesystem instance.
288 *
289 * Uses WP_Filesystem_Direct explicitly instead of WP_Filesystem(),
290 * which may fall back to FTP on servers where ABSPATH is not writable.
291 * The uploads directory (where cache files live) is always writable by
292 * the web server — the same assumption WordPress core makes for media
293 * uploads in _wp_handle_upload().
294 *
295 * @since 8.0.0
296 *
297 * @return \WP_Filesystem_Direct The direct filesystem instance.
298 */
299 protected static function get_filesystem() {
300 static $filesystem = null;
301
302 if ( null === $filesystem ) {
303 require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
304 require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
305
306 $filesystem = new \WP_Filesystem_Direct( null );
307 }
308
309 return $filesystem;
310 }
311
312 /**
313 * Delete a directory and all its contents.
314 *
315 * @since 8.0.0
316 *
317 * @param string $basedir The directory path to delete.
318 *
319 * @return bool True on success or if directory doesn't exist, false on failure.
320 */
321 public static function delete_directory( $basedir ) {
322 if ( ! \is_dir( $basedir ) ) {
323 return true;
324 }
325
326 return static::get_filesystem()->rmdir( $basedir, true );
327 }
328
329 /**
330 * Generate a hash for a URL.
331 *
332 * Uses full MD5 hash (32 characters) for better collision resistance.
333 * With truncated hashes, collision probability increases significantly
334 * at scale.
335 *
336 * @param string $url The URL to hash.
337 *
338 * @return string The full MD5 hash string (32 characters).
339 */
340 protected static function generate_hash( $url ) {
341 return \md5( $url );
342 }
343
344 /**
345 * Escape glob metacharacters in a pattern.
346 *
347 * This prevents special characters (*, ?, [, ]) from being interpreted
348 * as glob patterns when searching for files.
349 *
350 * @param string $pattern The pattern to escape.
351 *
352 * @return string The escaped pattern safe for use in glob().
353 */
354 protected static function escape_glob_pattern( $pattern ) {
355 return \preg_replace( '/([*?\[\]])/', '[$1]', $pattern );
356 }
357
358 /**
359 * Validate a URL is safe to fetch.
360 *
361 * @param string $url The URL to validate.
362 *
363 * @return bool True if URL is safe to fetch, false otherwise.
364 */
365 protected static function is_safe_url( $url ) {
366 if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
367 return false;
368 }
369
370 /**
371 * Filters whether a URL passes safety validation.
372 *
373 * By default, uses wp_http_validate_url() which prevents SSRF attacks
374 * by blocking private IPs and localhost. This filter allows overriding
375 * for testing or custom validation needs.
376 *
377 * @since 5.6.0
378 *
379 * @param bool|null $is_safe Whether the URL is safe. Return true/false to override,
380 * or null to use default wp_http_validate_url() check.
381 * @param string $url The URL being validated.
382 */
383 $is_safe = \apply_filters( 'activitypub_cache_is_safe_url', null, $url );
384
385 if ( null !== $is_safe ) {
386 return (bool) $is_safe;
387 }
388
389 return (bool) \wp_http_validate_url( $url );
390 }
391
392 /**
393 * Get allowed MIME types for this cache type.
394 *
395 * @return array Array of allowed MIME types.
396 */
397 protected static function get_allowed_mime_types() {
398 $type = static::get_type();
399
400 /**
401 * Filters the allowed MIME types for a cache type.
402 *
403 * Use this filter to add or remove allowed MIME types.
404 *
405 * @since 5.6.0
406 *
407 * @param array $mime_types Array of allowed MIME types.
408 * @param string $type The cache type ('avatar', 'media', 'emoji').
409 */
410 return (array) \apply_filters( 'activitypub_cache_allowed_mime_types', static::DEFAULT_ALLOWED_MIME_TYPES, $type );
411 }
412
413 /**
414 * Download and validate a remote file.
415 *
416 * @param string $url The remote URL to download.
417 *
418 * @return array|\WP_Error {
419 * Array on success, WP_Error on failure.
420 *
421 * @type string $file Path to downloaded file.
422 * @type string $mime_type Validated MIME type.
423 * }
424 */
425 protected static function download_and_validate( $url ) {
426 $type = static::get_type();
427
428 /**
429 * Filters the download result before fetching a URL.
430 *
431 * Allows short-circuiting the download process by providing a pre-downloaded
432 * file path. Useful for testing or when files are already available locally.
433 *
434 * @since 5.6.0
435 *
436 * @param array|null $result {
437 * Return null to proceed with download, or array with file info.
438 *
439 * @type string $file Path to the downloaded file.
440 * @type string $mime_type The file's MIME type.
441 * }
442 * @param string $url The URL that would be downloaded.
443 * @param string $type The cache type ('avatar', 'media', 'emoji').
444 */
445 $pre_download = \apply_filters( 'activitypub_pre_download_url', null, $url, $type );
446
447 if ( null !== $pre_download ) {
448 return $pre_download;
449 }
450
451 /**
452 * Filters whether a URL should be cached.
453 *
454 * Allows preventing specific URLs from being downloaded and cached.
455 * Return false to skip caching this URL.
456 *
457 * @since 5.6.0
458 *
459 * @param bool $should_cache Whether to cache this URL. Default true.
460 * @param string $url The remote URL.
461 * @param string $type The cache type ('avatar', 'media', 'emoji').
462 */
463 $should_cache = \apply_filters( 'activitypub_should_cache_url', true, $url, $type );
464
465 if ( ! $should_cache ) {
466 return new \WP_Error( 'cache_skipped', \__( 'URL caching was skipped by filter.', 'activitypub' ) );
467 }
468
469 // Validate URL is safe to fetch.
470 if ( ! static::is_safe_url( $url ) ) {
471 return new \WP_Error( 'invalid_url', \__( 'URL is not allowed.', 'activitypub' ) );
472 }
473
474 if ( ! \function_exists( 'download_url' ) ) {
475 require_once ABSPATH . 'wp-admin/includes/file.php';
476 }
477
478 $tmp_file = \download_url( $url, 15 ); // 15 second timeout.
479
480 if ( \is_wp_error( $tmp_file ) ) {
481 return $tmp_file;
482 }
483
484 // Validate file size.
485 $file_size = \filesize( $tmp_file );
486 if ( $file_size > static::MAX_FILE_SIZE ) {
487 \wp_delete_file( $tmp_file );
488 return new \WP_Error( 'file_too_large', \__( 'File exceeds maximum size limit.', 'activitypub' ) );
489 }
490
491 // Validate MIME type.
492 $validation = static::validate_mime_type( $tmp_file );
493 if ( \is_wp_error( $validation ) ) {
494 \wp_delete_file( $tmp_file );
495 return $validation;
496 }
497
498 // Get the validated file path (may have been renamed).
499 $file_path = \is_string( $validation ) ? $validation : $tmp_file;
500 $mime_type = static::get_file_mime_type( $file_path );
501
502 return array(
503 'file' => $file_path,
504 'mime_type' => $mime_type,
505 );
506 }
507
508 /**
509 * Validate MIME type of a file using multiple methods.
510 *
511 * This method addresses potential wp_get_image_mime() bypass concerns
512 * by using finfo, getimagesize, and wp_check_filetype_and_ext for validation.
513 *
514 * @param string $file_path Path to the file.
515 *
516 * @return string|\WP_Error File path (possibly renamed) on success, WP_Error on failure.
517 */
518 protected static function validate_mime_type( $file_path ) {
519 $allowed_mime_types = static::get_allowed_mime_types();
520
521 // Require fileinfo extension for validation.
522 if ( ! \extension_loaded( 'fileinfo' ) ) {
523 return new \WP_Error( 'finfo_failed', \__( 'Fileinfo extension not available.', 'activitypub' ) );
524 }
525
526 // Method 1: Use cached finfo instance for reliable MIME detection.
527 if ( null === self::$finfo ) {
528 self::$finfo = new \finfo( FILEINFO_MIME_TYPE );
529 }
530
531 $mime = self::$finfo->file( $file_path );
532
533 if ( ! \in_array( $mime, $allowed_mime_types, true ) ) {
534 return new \WP_Error( 'invalid_mime', \__( 'File type not allowed.', 'activitypub' ) );
535 }
536
537 // Method 2: Verify it's actually a valid image.
538 $image_info = @\getimagesize( $file_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
539 if ( false === $image_info ) {
540 return new \WP_Error( 'invalid_image', \__( 'File is not a valid image.', 'activitypub' ) );
541 }
542
543 // Verify image can actually be rendered.
544 if ( ! \function_exists( 'file_is_displayable_image' ) ) {
545 require_once ABSPATH . 'wp-admin/includes/image.php';
546 }
547
548 if ( ! \file_is_displayable_image( $file_path ) ) {
549 return new \WP_Error( 'not_displayable', \__( 'Image cannot be displayed.', 'activitypub' ) );
550 }
551
552 /*
553 * Method 3: Use WordPress's wp_check_filetype_and_ext for additional validation.
554 * MIME type restriction is already enforced by finfo in Method 1; this cross-checks
555 * that file content matches the declared type using WordPress defaults.
556 */
557 $expected_ext = \wp_get_default_extension_for_mime_type( $mime );
558
559 // Use the detected extension since temp files from download_url() have a .tmp extension.
560 $file_name = \pathinfo( \wp_basename( $file_path ), PATHINFO_FILENAME ) . '.' . $expected_ext;
561 $file_info = \wp_check_filetype_and_ext( $file_path, $file_name );
562
563 // If WordPress couldn't validate the file type, reject it.
564 if ( empty( $file_info['type'] ) || ! \str_starts_with( $file_info['type'], 'image/' ) ) {
565 return new \WP_Error( 'invalid_file_type', \__( 'File type validation failed.', 'activitypub' ) );
566 }
567
568 // Method 4: Ensure file extension matches MIME type.
569 $ext = \pathinfo( $file_path, PATHINFO_EXTENSION );
570
571 if ( \strtolower( $ext ) !== $expected_ext ) {
572 $new_path = \preg_replace( '/\.[^.]+$/', '.' . $expected_ext, $file_path );
573 if ( empty( $new_path ) || $new_path === $file_path ) {
574 $new_path = $file_path . '.' . $expected_ext;
575 }
576
577 if ( static::get_filesystem()->move( $file_path, $new_path, true ) ) {
578 return $new_path;
579 }
580 }
581
582 return $file_path;
583 }
584
585 /**
586 * Get the MIME type of a file.
587 *
588 * @param string $file_path Path to the file.
589 *
590 * @return string The MIME type.
591 */
592 protected static function get_file_mime_type( $file_path ) {
593 if ( \extension_loaded( 'fileinfo' ) ) {
594 if ( null === self::$finfo ) {
595 self::$finfo = new \finfo( FILEINFO_MIME_TYPE );
596 }
597 return self::$finfo->file( $file_path );
598 }
599
600 // Fallback to WordPress function.
601 return \wp_check_filetype( $file_path )['type'] ?? '';
602 }
603
604 /**
605 * Optimize an image file by resizing and converting to WebP.
606 *
607 * Uses WordPress image editor to resize large images and convert them
608 * to WebP format for better compression while maintaining quality.
609 *
610 * @param string $file_path Path to the image file.
611 * @param int $max_dimension Maximum width/height in pixels.
612 *
613 * @return string The optimized file path.
614 */
615 protected static function optimize_image( $file_path, $max_dimension ) {
616 // Check if it's an image.
617 $mime_type = static::get_file_mime_type( $file_path );
618 if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
619 return $file_path;
620 }
621
622 $editor = \wp_get_image_editor( $file_path );
623 if ( \is_wp_error( $editor ) ) {
624 return $file_path;
625 }
626
627 $size = $editor->get_size();
628 $needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;
629
630 // Resize if needed.
631 if ( $needs_resize ) {
632 $editor->resize( $max_dimension, $max_dimension, false );
633 }
634
635 // Check if WebP is supported.
636 $can_webp = $editor->supports_mime_type( 'image/webp' );
637
638 // Determine output format and save.
639 $dir = \dirname( $file_path );
640
641 if ( $can_webp ) {
642 // Convert to WebP.
643 $new_name = \wp_unique_filename( $dir, \preg_replace( '/\.[^.]+$/', '.webp', \basename( $file_path ) ) );
644 $result = $editor->save( $dir . '/' . $new_name, 'image/webp' );
645 } elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
646 // Keep original format for potentially transparent images when WebP not available.
647 if ( ! $needs_resize ) {
648 return $file_path;
649 }
650 $result = $editor->save( $file_path );
651 } else {
652 // Convert to JPEG when WebP not available.
653 $new_name = \wp_unique_filename( $dir, \preg_replace( '/\.[^.]+$/', '.jpg', \basename( $file_path ) ) );
654 $result = $editor->save( $dir . '/' . $new_name, 'image/jpeg' );
655 }
656
657 if ( \is_wp_error( $result ) ) {
658 return $file_path;
659 }
660
661 // Handle result.
662 $result_path = $result['path'] ?? $file_path;
663
664 // If path changed (format conversion), delete the original file.
665 if ( $result_path !== $file_path ) {
666 \wp_delete_file( $file_path );
667 }
668
669 return $result_path;
670 }
671 }
672