diff --git a/.github/changelog/fix-filesystem-graceful-failure b/.github/changelog/fix-filesystem-graceful-failure new file mode 100644 index 0000000000..2d64ac3f4b --- /dev/null +++ b/.github/changelog/fix-filesystem-graceful-failure @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fix a crash on servers where WordPress uses FTP instead of direct file access for media caching. diff --git a/includes/cache/class-file.php b/includes/cache/class-file.php index 86d95ef137..73fde51270 100644 --- a/includes/cache/class-file.php +++ b/includes/cache/class-file.php @@ -48,6 +48,7 @@ abstract class File { */ private static $finfo = null; + /** * Get the cache type identifier. * @@ -236,20 +237,8 @@ public static function cache( $url, $entity_id, $options = array() ) { $file_name = $hash . '.' . $ext; $file_path = $paths['basedir'] . '/' . $file_name; - // Initialize filesystem. - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } - - if ( ! $wp_filesystem ) { - \wp_delete_file( $tmp_file ); - return false; - } - // Move file to destination. - if ( ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) { + if ( ! static::get_filesystem()->move( $tmp_file, $file_path, true ) ) { \wp_delete_file( $tmp_file ); return false; } @@ -289,23 +278,52 @@ public static function cache( $url, $entity_id, $options = array() ) { * @return bool True on success, false on failure. */ public static function invalidate_entity( $entity_id ) { - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } + $paths = static::get_storage_paths( $entity_id ); - if ( ! $wp_filesystem ) { - return false; + 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 unreleased + * + * @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 ); } - $paths = static::get_storage_paths( $entity_id ); + return $filesystem; + } - if ( $wp_filesystem->is_dir( $paths['basedir'] ) ) { - return $wp_filesystem->rmdir( $paths['basedir'], true ); + /** + * Delete a directory and all its contents. + * + * @since unreleased + * + * @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 true; + return static::get_filesystem()->rmdir( $basedir, true ); } /** @@ -551,19 +569,12 @@ protected static function validate_mime_type( $file_path ) { $ext = \pathinfo( $file_path, PATHINFO_EXTENSION ); if ( strtolower( $ext ) !== $expected_ext ) { - // Rename file to correct extension using WP_Filesystem. - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } - $new_path = \preg_replace( '/\.[^.]+$/', '.' . $expected_ext, $file_path ); if ( empty( $new_path ) || $new_path === $file_path ) { $new_path = $file_path . '.' . $expected_ext; } - if ( $wp_filesystem && $wp_filesystem->move( $file_path, $new_path, true ) ) { + if ( static::get_filesystem()->move( $file_path, $new_path, true ) ) { return $new_path; } } diff --git a/includes/cache/class-media.php b/includes/cache/class-media.php index 096c857523..c4884ff72e 100644 --- a/includes/cache/class-media.php +++ b/includes/cache/class-media.php @@ -158,73 +158,11 @@ public static function maybe_cache( $url, $context, $entity_id = null, $options return $url; } - $cached_url = self::cache_url( $url, $entity_id ); + $cached_url = self::get_or_cache( $url, $entity_id ); return $cached_url ?: $url; } - /** - * Cache a single URL. - * - * @param string $url The remote URL to cache. - * @param int $entity_id The entity ID (post or comment). - * - * @return string|false The cached local URL, or false on failure. - */ - public static function cache_url( $url, $entity_id ) { - $paths = self::get_storage_paths_for_context( $entity_id, self::CONTEXT ); - $hash = self::generate_hash( $url ); - - // Check if already cached. - if ( \is_dir( $paths['basedir'] ) ) { - $pattern = self::escape_glob_pattern( $paths['basedir'] . '/' . $hash ) . '.*'; - $matches = \glob( $pattern ); - if ( ! empty( $matches ) && \is_file( $matches[0] ) ) { - return $paths['baseurl'] . '/' . \basename( $matches[0] ); - } - } - - // Download and cache. - $result = self::download_and_validate( $url ); - if ( \is_wp_error( $result ) || empty( $result['file'] ) ) { - return false; - } - - $tmp_file = $result['file']; - - // Create directory if needed. - if ( ! \wp_mkdir_p( $paths['basedir'] ) ) { - \wp_delete_file( $tmp_file ); - return false; - } - - // Generate hash-based filename. - $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; - - // Initialize filesystem. - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } - - if ( ! $wp_filesystem || ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) { - \wp_delete_file( $tmp_file ); - return false; - } - - // Optimize image. - $file_path = self::optimize_image( $file_path, self::MAX_DIMENSION ); - $file_name = \basename( $file_path ); - - return $paths['baseurl'] . '/' . $file_name; - } - /** * Maybe clean up cached media when post is deleted. * @@ -246,22 +184,8 @@ public static function maybe_cleanup( $post_id ) { * @return bool True on success, false on failure. */ public static function invalidate_comment( $comment_id ) { - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } - - if ( ! $wp_filesystem ) { - return false; - } - $paths = self::get_storage_paths_for_context( $comment_id, self::CONTEXT_COMMENT ); - if ( $wp_filesystem->is_dir( $paths['basedir'] ) ) { - return $wp_filesystem->rmdir( $paths['basedir'], true ); - } - - return true; + return static::delete_directory( $paths['basedir'] ); } } diff --git a/includes/class-attachments.php b/includes/class-attachments.php index 846261a02c..0bfe805ad2 100644 --- a/includes/class-attachments.php +++ b/includes/class-attachments.php @@ -201,15 +201,11 @@ private static function save_attachment( $attachment_data, $post_id, $author_id require_once ABSPATH . 'wp-admin/includes/image.php'; } - // Initialize filesystem. - global $wp_filesystem; - if ( ! $wp_filesystem ) { - \WP_Filesystem(); - } + // Use WP_Filesystem_Direct explicitly to avoid FTP fallback from WP_Filesystem(). + require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; - if ( ! $wp_filesystem ) { - return new \WP_Error( 'filesystem_error', \__( 'Could not initialize filesystem.', 'activitypub' ) ); - } + $filesystem = new \WP_Filesystem_Direct( null ); $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] ); @@ -221,14 +217,14 @@ private static function save_attachment( $attachment_data, $post_id, $author_id } // Read local file from disk. - if ( ! $wp_filesystem->exists( $attachment_data['url'] ) ) { + if ( ! $filesystem->exists( $attachment_data['url'] ) ) { /* translators: %s: file path */ return new \WP_Error( 'file_not_found', sprintf( \__( 'File not found: %s', 'activitypub' ), $attachment_data['url'] ) ); } // Copy to temp file so media_handle_sideload doesn't move the original. $tmp_file = \wp_tempnam( \basename( $attachment_data['url'] ) ); - $wp_filesystem->copy( $attachment_data['url'], $tmp_file, true ); + $filesystem->copy( $attachment_data['url'], $tmp_file, true ); } else { // Validate remote URL before downloading. if ( ! \wp_http_validate_url( $attachment_data['url'] ) ) { @@ -250,7 +246,7 @@ private static function save_attachment( $attachment_data, $post_id, $author_id $original_ext = \pathinfo( $original_name, PATHINFO_EXTENSION ); if ( $original_ext ) { $renamed_tmp = $tmp_file . '.' . $original_ext; - if ( $wp_filesystem->move( $tmp_file, $renamed_tmp, true ) ) { + if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) { $tmp_file = $renamed_tmp; } } diff --git a/includes/cli/class-cache-command.php b/includes/cli/class-cache-command.php index e086870bfd..edb554c4ff 100644 --- a/includes/cli/class-cache-command.php +++ b/includes/cli/class-cache-command.php @@ -9,6 +9,7 @@ use Activitypub\Cache\Avatar; use Activitypub\Cache\Emoji; +use Activitypub\Cache\File; use Activitypub\Cache\Media; /** @@ -200,24 +201,13 @@ private function clear_cache_type( $type ) { return 0; } - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } - - if ( ! $wp_filesystem ) { - \WP_CLI::warning( 'Could not initialize filesystem.' ); - return 0; - } - // Count subdirectories before clearing. $subdirs = \glob( $base_dir . '/*', GLOB_ONLYDIR ); $count = $subdirs ? \count( $subdirs ) : 0; - // Remove all subdirectories. + // Remove all subdirectories using the cache's native delete_directory helper. foreach ( $subdirs as $subdir ) { - $wp_filesystem->rmdir( $subdir, true ); + File::delete_directory( $subdir ); } // Also clear avatar URL meta for avatar cache. diff --git a/includes/wp-admin/class-health-check.php b/includes/wp-admin/class-health-check.php index 98d0fc8bc1..25bb7f1697 100644 --- a/includes/wp-admin/class-health-check.php +++ b/includes/wp-admin/class-health-check.php @@ -148,6 +148,11 @@ public static function add_tests( $tests ) { 'test' => array( self::class, 'test_outbox_rate' ), ); + $tests['direct']['activitypub_test_filesystem'] = array( + 'label' => \__( 'Filesystem Access Test', 'activitypub' ), + 'test' => array( self::class, 'test_filesystem' ), + ); + return $tests; } @@ -837,6 +842,61 @@ public static function test_outbox_rate() { return $result; } + /** + * Filesystem access test. + * + * Checks whether WordPress can write files directly. When direct filesystem + * access is unavailable (e.g., FTP-only servers), media caching will not work. + * + * @since unreleased + * + * @return array The test result. + */ + public static function test_filesystem() { + $result = array( + 'label' => \__( 'Uploads directory is writable for media caching', 'activitypub' ), + 'status' => 'good', + 'badge' => array( + 'label' => \__( 'ActivityPub', 'activitypub' ), + 'color' => 'green', + ), + 'description' => \sprintf( + '
%s
', + \__( 'The uploads directory is writable, so remote media caching (avatars, emoji, images) works correctly.', 'activitypub' ) + ), + 'actions' => '', + 'test' => 'test_filesystem', + ); + + // Skip test when remote caching is disabled. + if ( ACTIVITYPUB_DISABLE_REMOTE_CACHE ) { + $result['description'] = \sprintf( + '%s
', + \__( 'Remote media caching is disabled, so uploads directory write access is not required.', 'activitypub' ) + ); + return $result; + } + + $upload_dir = \wp_upload_dir(); + + if ( ! \wp_is_writable( $upload_dir['basedir'] ) ) { + $result['status'] = 'recommended'; + $result['label'] = \__( 'Uploads directory is not writable for media caching', 'activitypub' ); + $result['badge']['color'] = 'orange'; + $result['description'] = \sprintf( + '%s
%s
', + \__( 'The uploads directory is not writable by the web server. Remote avatars, emoji, and images will be served from their original URLs instead of being cached locally.', 'activitypub' ), + \__( 'To fix this, ask your hosting provider to ensure the uploads directory is writable by the web server.', 'activitypub' ) + ); + $result['actions'] = \sprintf( + '%s
', + \__( 'If you cannot make the directory writable, you can disable media caching by addingdefine( \'ACTIVITYPUB_DISABLE_REMOTE_CACHE\', true ); to your wp-config.php. Remote media will then be served from its original URL.', 'activitypub' )
+ );
+ }
+
+ return $result;
+ }
+
/**
* Count outbox items created in the last hour.
*