PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
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 trunk, at includes/cache/class-file.php

683 lines 20.5 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 /*
241 * Move the file to its destination, once more after re-creating the directory if that
242 * fails. Caching an entity's file races with invalidating that same entity, which deletes
243 * the whole directory: when the delete lands between the two, the move has nowhere to put
244 * the file and the download is lost for no reason.
245 */
246 if ( ! static::get_filesystem()->move( $tmp_file, $file_path, true ) ) {
247 if ( ! \wp_mkdir_p( $paths['basedir'] ) || ! static::get_filesystem()->move( $tmp_file, $file_path, true ) ) {
248 \wp_delete_file( $tmp_file );
249 return false;
250 }
251 }
252
253 // Optimize image if applicable.
254 $max_dimension = $options['max_dimension'] ?? static::get_max_dimension();
255 $file_path = static::optimize_image( $file_path, $max_dimension );
256 $file_name = \basename( $file_path );
257
258 $local_url = $paths['baseurl'] . '/' . $file_name;
259
260 /**
261 * Fires after a remote media file has been successfully cached.
262 *
263 * Use this hook for logging, analytics, or post-processing.
264 *
265 * @since 5.6.0
266 *
267 * @param string $local_url The local URL of the cached file.
268 * @param string $url The original remote URL.
269 * @param string|int $entity_id The entity identifier.
270 * @param string $type The cache type ('avatar', 'media', 'emoji').
271 * @param string $file_path The local file system path.
272 */
273 \do_action( 'activitypub_media_cached', $local_url, $url, $entity_id, static::get_type(), $file_path );
274
275 return $local_url;
276 }
277
278 /**
279 * Invalidate cached files for an entity.
280 *
281 * Deletes the entire entity directory and all its contents.
282 *
283 * @param string|int $entity_id The entity identifier.
284 *
285 * @return bool True on success, false on failure.
286 */
287 public static function invalidate_entity( $entity_id ) {
288 $paths = static::get_storage_paths( $entity_id );
289
290 return static::delete_directory( $paths['basedir'] );
291 }
292
293 /**
294 * Get a direct filesystem instance.
295 *
296 * Uses WP_Filesystem_Direct explicitly instead of WP_Filesystem(),
297 * which may fall back to FTP on servers where ABSPATH is not writable.
298 * The uploads directory (where cache files live) is always writable by
299 * the web server — the same assumption WordPress core makes for media
300 * uploads in _wp_handle_upload().
301 *
302 * @since 8.0.0
303 *
304 * @return \WP_Filesystem_Direct The direct filesystem instance.
305 */
306 protected static function get_filesystem() {
307 static $filesystem = null;
308
309 if ( null === $filesystem ) {
310 require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
311 require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
312
313 $filesystem = new \WP_Filesystem_Direct( null );
314 }
315
316 return $filesystem;
317 }
318
319 /**
320 * Delete a directory and all its contents.
321 *
322 * @since 8.0.0
323 *
324 * @param string $basedir The directory path to delete.
325 *
326 * @return bool True on success or if directory doesn't exist, false on failure.
327 */
328 public static function delete_directory( $basedir ) {
329 if ( ! \is_dir( $basedir ) ) {
330 return true;
331 }
332
333 return static::get_filesystem()->rmdir( $basedir, true );
334 }
335
336 /**
337 * Generate a hash for a URL.
338 *
339 * Uses full MD5 hash (32 characters) for better collision resistance.
340 * With truncated hashes, collision probability increases significantly
341 * at scale.
342 *
343 * @param string $url The URL to hash.
344 *
345 * @return string The full MD5 hash string (32 characters).
346 */
347 protected static function generate_hash( $url ) {
348 return \md5( $url );
349 }
350
351 /**
352 * Escape glob metacharacters in a pattern.
353 *
354 * This prevents special characters (*, ?, [, ]) from being interpreted
355 * as glob patterns when searching for files.
356 *
357 * @param string $pattern The pattern to escape.
358 *
359 * @return string The escaped pattern safe for use in glob().
360 */
361 protected static function escape_glob_pattern( $pattern ) {
362 return \preg_replace( '/([*?\[\]])/', '[$1]', $pattern );
363 }
364
365 /**
366 * Validate a URL is safe to fetch.
367 *
368 * @param string $url The URL to validate.
369 *
370 * @return bool True if URL is safe to fetch, false otherwise.
371 */
372 protected static function is_safe_url( $url ) {
373 if ( empty( $url ) || ! \filter_var( $url, FILTER_VALIDATE_URL ) ) {
374 return false;
375 }
376
377 /**
378 * Filters whether a URL passes safety validation.
379 *
380 * By default, uses wp_http_validate_url() which prevents SSRF attacks
381 * by blocking private IPs and localhost. This filter allows overriding
382 * for testing or custom validation needs.
383 *
384 * @since 5.6.0
385 *
386 * @param bool|null $is_safe Whether the URL is safe. Return true/false to override,
387 * or null to use default wp_http_validate_url() check.
388 * @param string $url The URL being validated.
389 */
390 $is_safe = \apply_filters( 'activitypub_cache_is_safe_url', null, $url );
391
392 if ( null !== $is_safe ) {
393 return (bool) $is_safe;
394 }
395
396 return (bool) \wp_http_validate_url( $url );
397 }
398
399 /**
400 * Get allowed MIME types for this cache type.
401 *
402 * @return array Array of allowed MIME types.
403 */
404 protected static function get_allowed_mime_types() {
405 $type = static::get_type();
406
407 /**
408 * Filters the allowed MIME types for a cache type.
409 *
410 * Use this filter to add or remove allowed MIME types.
411 *
412 * @since 5.6.0
413 *
414 * @param array $mime_types Array of allowed MIME types.
415 * @param string $type The cache type ('avatar', 'media', 'emoji').
416 */
417 return (array) \apply_filters( 'activitypub_cache_allowed_mime_types', static::DEFAULT_ALLOWED_MIME_TYPES, $type );
418 }
419
420 /**
421 * Download and validate a remote file.
422 *
423 * @param string $url The remote URL to download.
424 *
425 * @return array|\WP_Error {
426 * Array on success, WP_Error on failure.
427 *
428 * @type string $file Path to downloaded file.
429 * @type string $mime_type Validated MIME type.
430 * }
431 */
432 protected static function download_and_validate( $url ) {
433 $type = static::get_type();
434
435 /**
436 * Filters the download result before fetching a URL.
437 *
438 * Allows short-circuiting the download process by providing a pre-downloaded
439 * file path. Useful for testing or when files are already available locally.
440 *
441 * @since 5.6.0
442 *
443 * @param array|null $result {
444 * Return null to proceed with download, or array with file info.
445 *
446 * @type string $file Path to the downloaded file.
447 * @type string $mime_type The file's MIME type.
448 * }
449 * @param string $url The URL that would be downloaded.
450 * @param string $type The cache type ('avatar', 'media', 'emoji').
451 */
452 $pre_download = \apply_filters( 'activitypub_pre_download_url', null, $url, $type );
453
454 if ( null !== $pre_download ) {
455 return $pre_download;
456 }
457
458 /**
459 * Filters whether a URL should be cached.
460 *
461 * Allows preventing specific URLs from being downloaded and cached.
462 * Return false to skip caching this URL.
463 *
464 * @since 5.6.0
465 *
466 * @param bool $should_cache Whether to cache this URL. Default true.
467 * @param string $url The remote URL.
468 * @param string $type The cache type ('avatar', 'media', 'emoji').
469 */
470 $should_cache = \apply_filters( 'activitypub_should_cache_url', true, $url, $type );
471
472 if ( ! $should_cache ) {
473 return new \WP_Error( 'cache_skipped', \__( 'URL caching was skipped by filter.', 'activitypub' ) );
474 }
475
476 // Validate URL is safe to fetch.
477 if ( ! static::is_safe_url( $url ) ) {
478 return new \WP_Error( 'invalid_url', \__( 'URL is not allowed.', 'activitypub' ) );
479 }
480
481 if ( ! \function_exists( 'download_url' ) ) {
482 require_once ABSPATH . 'wp-admin/includes/file.php';
483 }
484
485 $tmp_file = \download_url( $url, 15 ); // 15 second timeout.
486
487 if ( \is_wp_error( $tmp_file ) ) {
488 return $tmp_file;
489 }
490
491 // Validate file size.
492 $file_size = \filesize( $tmp_file );
493 if ( $file_size > static::MAX_FILE_SIZE ) {
494 \wp_delete_file( $tmp_file );
495 return new \WP_Error( 'file_too_large', \__( 'File exceeds maximum size limit.', 'activitypub' ) );
496 }
497
498 // Validate MIME type.
499 $validation = static::validate_mime_type( $tmp_file );
500 if ( \is_wp_error( $validation ) ) {
501 \wp_delete_file( $tmp_file );
502 return $validation;
503 }
504
505 // Get the validated file path (may have been renamed).
506 $file_path = \is_string( $validation ) ? $validation : $tmp_file;
507 $mime_type = static::get_file_mime_type( $file_path );
508
509 return array(
510 'file' => $file_path,
511 'mime_type' => $mime_type,
512 );
513 }
514
515 /**
516 * Validate MIME type of a file using multiple methods.
517 *
518 * This method addresses potential wp_get_image_mime() bypass concerns
519 * by using finfo, getimagesize, and wp_check_filetype_and_ext for validation.
520 *
521 * @param string $file_path Path to the file.
522 *
523 * @return string|\WP_Error File path (possibly renamed) on success, WP_Error on failure.
524 */
525 protected static function validate_mime_type( $file_path ) {
526 $allowed_mime_types = static::get_allowed_mime_types();
527
528 // Require fileinfo extension for validation.
529 if ( ! \extension_loaded( 'fileinfo' ) ) {
530 return new \WP_Error( 'finfo_failed', \__( 'Fileinfo extension not available.', 'activitypub' ) );
531 }
532
533 // Method 1: Use cached finfo instance for reliable MIME detection.
534 if ( null === self::$finfo ) {
535 self::$finfo = new \finfo( FILEINFO_MIME_TYPE );
536 }
537
538 $mime = self::$finfo->file( $file_path );
539
540 if ( ! \in_array( $mime, $allowed_mime_types, true ) ) {
541 return new \WP_Error( 'invalid_mime', \__( 'File type not allowed.', 'activitypub' ) );
542 }
543
544 // Method 2: Verify it's actually a valid image.
545 $image_info = @\getimagesize( $file_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
546 if ( false === $image_info ) {
547 return new \WP_Error( 'invalid_image', \__( 'File is not a valid image.', 'activitypub' ) );
548 }
549
550 // Verify image can actually be rendered.
551 if ( ! \function_exists( 'file_is_displayable_image' ) ) {
552 require_once ABSPATH . 'wp-admin/includes/image.php';
553 }
554
555 if ( ! \file_is_displayable_image( $file_path ) ) {
556 return new \WP_Error( 'not_displayable', \__( 'Image cannot be displayed.', 'activitypub' ) );
557 }
558
559 /*
560 * Method 3: Use WordPress's wp_check_filetype_and_ext for additional validation.
561 * MIME type restriction is already enforced by finfo in Method 1; this cross-checks
562 * that file content matches the declared type using WordPress defaults.
563 */
564 $expected_ext = \wp_get_default_extension_for_mime_type( $mime );
565
566 // Use the detected extension since temp files from download_url() have a .tmp extension.
567 $file_name = \pathinfo( \wp_basename( $file_path ), PATHINFO_FILENAME ) . '.' . $expected_ext;
568 $file_info = \wp_check_filetype_and_ext( $file_path, $file_name );
569
570 // If WordPress couldn't validate the file type, reject it.
571 if ( empty( $file_info['type'] ) || ! \str_starts_with( $file_info['type'], 'image/' ) ) {
572 return new \WP_Error( 'invalid_file_type', \__( 'File type validation failed.', 'activitypub' ) );
573 }
574
575 // Method 4: Ensure file extension matches MIME type.
576 $ext = \pathinfo( $file_path, PATHINFO_EXTENSION );
577
578 if ( \strtolower( $ext ) !== $expected_ext ) {
579 $new_path = \preg_replace( '/\.[^.]+$/', '.' . $expected_ext, $file_path );
580 if ( empty( $new_path ) || $new_path === $file_path ) {
581 $new_path = $file_path . '.' . $expected_ext;
582 }
583
584 if ( static::get_filesystem()->move( $file_path, $new_path, true ) ) {
585 return $new_path;
586 }
587 }
588
589 return $file_path;
590 }
591
592 /**
593 * Get the MIME type of a file.
594 *
595 * @param string $file_path Path to the file.
596 *
597 * @return string The MIME type.
598 */
599 protected static function get_file_mime_type( $file_path ) {
600 if ( \extension_loaded( 'fileinfo' ) ) {
601 if ( null === self::$finfo ) {
602 self::$finfo = new \finfo( FILEINFO_MIME_TYPE );
603 }
604 return self::$finfo->file( $file_path );
605 }
606
607 // Fallback to WordPress function.
608 return \wp_check_filetype( $file_path )['type'] ?? '';
609 }
610
611 /**
612 * Optimize an image file by resizing and converting to WebP.
613 *
614 * Uses WordPress image editor to resize large images and convert them
615 * to WebP format for better compression while maintaining quality.
616 *
617 * @param string $file_path Path to the image file.
618 * @param int $max_dimension Maximum width/height in pixels.
619 *
620 * @return string The optimized file path.
621 */
622 protected static function optimize_image( $file_path, $max_dimension ) {
623 // Check if it's an image.
624 $mime_type = static::get_file_mime_type( $file_path );
625 if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) {
626 return $file_path;
627 }
628
629 $editor = \wp_get_image_editor( $file_path );
630 if ( \is_wp_error( $editor ) ) {
631 return $file_path;
632 }
633
634 $size = $editor->get_size();
635 $needs_resize = $size['width'] > $max_dimension || $size['height'] > $max_dimension;
636
637 // Resize if needed.
638 if ( $needs_resize ) {
639 $editor->resize( $max_dimension, $max_dimension, false );
640 }
641
642 // Check if WebP is supported.
643 $can_webp = $editor->supports_mime_type( 'image/webp' );
644
645 // Determine output format and save.
646 $dir = \dirname( $file_path );
647
648 if ( $can_webp ) {
649 // Convert to WebP.
650 $new_name = \wp_unique_filename( $dir, \preg_replace( '/\.[^.]+$/', '.webp', \basename( $file_path ) ) );
651 $result = $editor->save( $dir . '/' . $new_name, 'image/webp' );
652 } elseif ( \in_array( $mime_type, array( 'image/png', 'image/webp' ), true ) ) {
653 // Keep original format for potentially transparent images when WebP not available.
654 if ( ! $needs_resize ) {
655 return $file_path;
656 }
657 $result = $editor->save( $file_path );
658 } else {
659 // Convert to JPEG when WebP not available.
660 $new_name = \wp_unique_filename( $dir, \preg_replace( '/\.[^.]+$/', '.jpg', \basename( $file_path ) ) );
661 $result = $editor->save( $dir . '/' . $new_name, 'image/jpeg' );
662 }
663
664 if ( \is_wp_error( $result ) ) {
665 return $file_path;
666 }
667
668 // Handle result.
669 $result_path = $result['path'] ?? $file_path;
670
671 /*
672 * If the path changed (format conversion), delete the original file. It may already be
673 * gone: invalidating the entity deletes its whole directory, and that can land while this
674 * file is being converted. Deleting it again is not an error worth a diagnostic.
675 */
676 if ( $result_path !== $file_path && \file_exists( $file_path ) ) {
677 \wp_delete_file( $file_path );
678 }
679
680 return $result_path;
681 }
682 }
683