From 3cad435b2c0c11ed350968b265fea5ae68fe42c6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 24 Feb 2026 18:37:06 +0100 Subject: [PATCH 1/6] Fix crash when WordPress falls back to FTP filesystem Centralize filesystem access in Cache\File with get_filesystem() and delete_directory() helpers. The core fix: WP_Filesystem() can return false on failure but still set $wp_filesystem to a broken FTP object. The new helper checks the return value and nulls the global on failure, preventing fatal errors from calling methods on a non-functional object. Also adds a per-request failure flag to skip repeated init attempts and a Site Health warning to guide admins toward fixing or disabling caching. --- includes/cache/class-file.php | 82 +++++++++++++++++++----- includes/cache/class-media.php | 80 +---------------------- includes/class-attachments.php | 4 +- includes/wp-admin/class-health-check.php | 53 +++++++++++++++ 4 files changed, 124 insertions(+), 95 deletions(-) diff --git a/includes/cache/class-file.php b/includes/cache/class-file.php index 86d95ef137..a73c1a6c1b 100644 --- a/includes/cache/class-file.php +++ b/includes/cache/class-file.php @@ -48,6 +48,18 @@ abstract class File { */ private static $finfo = null; + /** + * Whether filesystem initialization has already failed this request. + * + * Prevents repeated WP_Filesystem() calls when the server lacks + * direct file access (e.g., FTP-only environments). + * + * @since unreleased + * + * @var bool + */ + private static $filesystem_failed = false; + /** * Get the cache type identifier. * @@ -237,11 +249,7 @@ public static function cache( $url, $entity_id, $options = array() ) { $file_path = $paths['basedir'] . '/' . $file_name; // Initialize filesystem. - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); - } + $wp_filesystem = static::get_filesystem(); if ( ! $wp_filesystem ) { \wp_delete_file( $tmp_file ); @@ -289,20 +297,66 @@ public static function cache( $url, $entity_id, $options = array() ) { * @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 properly initialized WP_Filesystem instance. + * + * Handles the case where WP_Filesystem() returns false on failure but + * still sets the global $wp_filesystem to a broken object (e.g., an + * FTP object that can't connect). This method checks the return value + * and nulls the global on failure to prevent fatal errors. + * + * @since unreleased + * + * @return \WP_Filesystem_Base|null The filesystem instance or null on failure. + */ + protected static function get_filesystem() { global $wp_filesystem; - if ( ! $wp_filesystem ) { + + if ( $wp_filesystem ) { + return $wp_filesystem; + } + + // Skip repeated initialization attempts within the same request. + if ( self::$filesystem_failed ) { + return null; + } + + if ( ! \function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; - \WP_Filesystem(); } + if ( ! \WP_Filesystem() ) { + $wp_filesystem = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally clearing broken FTP fallback object. + self::$filesystem_failed = true; + return null; + } + + return $wp_filesystem; + } + + /** + * 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. + */ + protected static function delete_directory( $basedir ) { + $wp_filesystem = static::get_filesystem(); + if ( ! $wp_filesystem ) { return false; } - $paths = static::get_storage_paths( $entity_id ); - - if ( $wp_filesystem->is_dir( $paths['basedir'] ) ) { - return $wp_filesystem->rmdir( $paths['basedir'], true ); + if ( $wp_filesystem->is_dir( $basedir ) ) { + return $wp_filesystem->rmdir( $basedir, true ); } return true; @@ -552,11 +606,7 @@ protected static function validate_mime_type( $file_path ) { 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(); - } + $wp_filesystem = static::get_filesystem(); $new_path = \preg_replace( '/\.[^.]+$/', '.' . $expected_ext, $file_path ); if ( empty( $new_path ) || $new_path === $file_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..a9b92ebf5f 100644 --- a/includes/class-attachments.php +++ b/includes/class-attachments.php @@ -204,7 +204,9 @@ private static function save_attachment( $attachment_data, $post_id, $author_id // Initialize filesystem. global $wp_filesystem; if ( ! $wp_filesystem ) { - \WP_Filesystem(); + if ( ! \WP_Filesystem() ) { + $wp_filesystem = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally clearing broken FTP fallback object. + } } if ( ! $wp_filesystem ) { diff --git a/includes/wp-admin/class-health-check.php b/includes/wp-admin/class-health-check.php index 98d0fc8bc1..200e04a982 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,54 @@ 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' => \__( 'Filesystem access is available for media caching', 'activitypub' ), + 'status' => 'good', + 'badge' => array( + 'label' => \__( 'ActivityPub', 'activitypub' ), + 'color' => 'green', + ), + 'description' => \sprintf( + '

%s

', + \__( 'WordPress can write files directly, so remote media caching (avatars, emoji, images) works correctly.', 'activitypub' ) + ), + 'actions' => '', + 'test' => 'test_filesystem', + ); + + if ( ! \function_exists( 'WP_Filesystem' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + if ( ! \WP_Filesystem() ) { + $result['status'] = 'recommended'; + $result['label'] = \__( 'Filesystem access is not available for media caching', 'activitypub' ); + $result['badge']['color'] = 'orange'; + $result['description'] = \sprintf( + '

%s

%s

', + \__( 'WordPress cannot access the filesystem directly and is falling back to FTP, which is not supported for media caching. 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 enable direct filesystem access, or add define( \'FS_METHOD\', \'direct\' ); to your wp-config.php if your server permissions allow it.', 'activitypub' ) + ); + $result['actions'] = \sprintf( + '

%s

', + \__( 'If you cannot configure direct filesystem access, you can disable media caching by adding define( \'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. * From c5aa109bb642282c0a3ecdf373b88f155b148f52 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 24 Feb 2026 18:37:55 +0100 Subject: [PATCH 2/6] Add changelog entry --- .github/changelog/fix-filesystem-graceful-failure | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/fix-filesystem-graceful-failure 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. From dafc11466641d92e7b3af5a538da41cf86e919ea Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 24 Feb 2026 19:04:17 +0100 Subject: [PATCH 3/6] Address review feedback for filesystem health check Skip the test when ACTIVITYPUB_DISABLE_REMOTE_CACHE is set, and null the broken $wp_filesystem global on failure to avoid side effects for other health checks running in the same request. --- includes/wp-admin/class-health-check.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/includes/wp-admin/class-health-check.php b/includes/wp-admin/class-health-check.php index 200e04a982..ea99108805 100644 --- a/includes/wp-admin/class-health-check.php +++ b/includes/wp-admin/class-health-check.php @@ -868,11 +868,27 @@ public static function test_filesystem() { 'test' => 'test_filesystem', ); + // Skip test when remote caching is disabled — filesystem access is not needed. + if ( ACTIVITYPUB_DISABLE_REMOTE_CACHE ) { + $result['description'] = \sprintf( + '

%s

', + \__( 'Remote media caching is disabled, so filesystem access is not required.', 'activitypub' ) + ); + return $result; + } + if ( ! \function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } - if ( ! \WP_Filesystem() ) { + // Check filesystem without modifying global state for other health checks. + $filesystem_works = \WP_Filesystem(); + + if ( ! $filesystem_works ) { + // Clear the broken object that WP_Filesystem() may have left behind. + global $wp_filesystem; + $wp_filesystem = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally clearing broken FTP fallback object. + $result['status'] = 'recommended'; $result['label'] = \__( 'Filesystem access is not available for media caching', 'activitypub' ); $result['badge']['color'] = 'orange'; From 91fb71db8336c26783e25d6c53e3f2fee0d7611d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Feb 2026 08:48:10 +0100 Subject: [PATCH 4/6] Replace WP_Filesystem with native PHP for cache operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordPress core uses native PHP functions (rename, copy, etc.) for file operations in the uploads directory — not WP_Filesystem. WP_Filesystem checks write access to ABSPATH and falls back to FTP when that fails, even though the uploads directory itself is writable. This removes all WP_Filesystem usage from cache, attachments, and CLI code, matching how core handles files in the same directory. This eliminates the FTP fallback crash entirely rather than just handling it gracefully. The health check now tests uploads directory writability instead of WP_Filesystem availability. --- includes/cache/class-file.php | 97 +++++++----------------- includes/class-attachments.php | 20 ++--- includes/cli/class-cache-command.php | 15 +--- includes/wp-admin/class-health-check.php | 29 +++---- 4 files changed, 44 insertions(+), 117 deletions(-) diff --git a/includes/cache/class-file.php b/includes/cache/class-file.php index a73c1a6c1b..23e139e429 100644 --- a/includes/cache/class-file.php +++ b/includes/cache/class-file.php @@ -48,17 +48,6 @@ abstract class File { */ private static $finfo = null; - /** - * Whether filesystem initialization has already failed this request. - * - * Prevents repeated WP_Filesystem() calls when the server lacks - * direct file access (e.g., FTP-only environments). - * - * @since unreleased - * - * @var bool - */ - private static $filesystem_failed = false; /** * Get the cache type identifier. @@ -248,16 +237,9 @@ public static function cache( $url, $entity_id, $options = array() ) { $file_name = $hash . '.' . $ext; $file_path = $paths['basedir'] . '/' . $file_name; - // Initialize filesystem. - $wp_filesystem = static::get_filesystem(); - - if ( ! $wp_filesystem ) { - \wp_delete_file( $tmp_file ); - return false; - } - - // Move file to destination. - if ( ! $wp_filesystem->move( $tmp_file, $file_path, true ) ) { + // Move file to destination (using rename like WordPress core's _wp_handle_upload). + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Matching core behavior for uploads directory. + if ( ! \rename( $tmp_file, $file_path ) ) { \wp_delete_file( $tmp_file ); return false; } @@ -302,64 +284,41 @@ public static function invalidate_entity( $entity_id ) { return static::delete_directory( $paths['basedir'] ); } - /** - * Get a properly initialized WP_Filesystem instance. - * - * Handles the case where WP_Filesystem() returns false on failure but - * still sets the global $wp_filesystem to a broken object (e.g., an - * FTP object that can't connect). This method checks the return value - * and nulls the global on failure to prevent fatal errors. - * - * @since unreleased - * - * @return \WP_Filesystem_Base|null The filesystem instance or null on failure. - */ - protected static function get_filesystem() { - global $wp_filesystem; - - if ( $wp_filesystem ) { - return $wp_filesystem; - } - - // Skip repeated initialization attempts within the same request. - if ( self::$filesystem_failed ) { - return null; - } - - if ( ! \function_exists( 'WP_Filesystem' ) ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - if ( ! \WP_Filesystem() ) { - $wp_filesystem = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally clearing broken FTP fallback object. - self::$filesystem_failed = true; - return null; - } - - return $wp_filesystem; - } - /** * Delete a directory and all its contents. * + * Uses native PHP functions instead of WP_Filesystem, matching how + * WordPress core handles files in the uploads directory. This avoids + * the FTP fallback that WP_Filesystem() triggers on servers without + * direct filesystem access. + * * @since unreleased * * @param string $basedir The directory path to delete. * * @return bool True on success or if directory doesn't exist, false on failure. */ - protected static function delete_directory( $basedir ) { - $wp_filesystem = static::get_filesystem(); - - if ( ! $wp_filesystem ) { - return false; + public static function delete_directory( $basedir ) { + if ( ! \is_dir( $basedir ) ) { + return true; } - if ( $wp_filesystem->is_dir( $basedir ) ) { - return $wp_filesystem->rmdir( $basedir, true ); + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $basedir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $files as $file ) { + if ( $file->isDir() ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Using native PHP for uploads directory, matching core behavior. + \rmdir( $file->getRealPath() ); + } else { + \wp_delete_file( $file->getRealPath() ); + } } - return true; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Using native PHP for uploads directory, matching core behavior. + return \rmdir( $basedir ); } /** @@ -605,15 +564,13 @@ 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. - $wp_filesystem = static::get_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 ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Matching core behavior for uploads directory. + if ( \rename( $file_path, $new_path ) ) { return $new_path; } } diff --git a/includes/class-attachments.php b/includes/class-attachments.php index a9b92ebf5f..6c2840ff60 100644 --- a/includes/class-attachments.php +++ b/includes/class-attachments.php @@ -201,18 +201,6 @@ 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 ) { - if ( ! \WP_Filesystem() ) { - $wp_filesystem = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally clearing broken FTP fallback object. - } - } - - if ( ! $wp_filesystem ) { - return new \WP_Error( 'filesystem_error', \__( 'Could not initialize filesystem.', 'activitypub' ) ); - } - $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] ); if ( $is_local ) { @@ -223,14 +211,15 @@ private static function save_attachment( $attachment_data, $post_id, $author_id } // Read local file from disk. - if ( ! $wp_filesystem->exists( $attachment_data['url'] ) ) { + if ( ! \file_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 ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- Matching core behavior for uploads directory. + \copy( $attachment_data['url'], $tmp_file ); } else { // Validate remote URL before downloading. if ( ! \wp_http_validate_url( $attachment_data['url'] ) ) { @@ -252,7 +241,8 @@ 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 ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Matching core behavior for uploads directory. + if ( \rename( $tmp_file, $renamed_tmp ) ) { $tmp_file = $renamed_tmp; } } diff --git a/includes/cli/class-cache-command.php b/includes/cli/class-cache-command.php index e086870bfd..03414a5c61 100644 --- a/includes/cli/class-cache-command.php +++ b/includes/cli/class-cache-command.php @@ -200,24 +200,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 ); + Avatar::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 ea99108805..25bb7f1697 100644 --- a/includes/wp-admin/class-health-check.php +++ b/includes/wp-admin/class-health-check.php @@ -854,7 +854,7 @@ public static function test_outbox_rate() { */ public static function test_filesystem() { $result = array( - 'label' => \__( 'Filesystem access is available for media caching', 'activitypub' ), + 'label' => \__( 'Uploads directory is writable for media caching', 'activitypub' ), 'status' => 'good', 'badge' => array( 'label' => \__( 'ActivityPub', 'activitypub' ), @@ -862,44 +862,35 @@ public static function test_filesystem() { ), 'description' => \sprintf( '

%s

', - \__( 'WordPress can write files directly, so remote media caching (avatars, emoji, images) works correctly.', 'activitypub' ) + \__( '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 — filesystem access is not needed. + // Skip test when remote caching is disabled. if ( ACTIVITYPUB_DISABLE_REMOTE_CACHE ) { $result['description'] = \sprintf( '

%s

', - \__( 'Remote media caching is disabled, so filesystem access is not required.', 'activitypub' ) + \__( 'Remote media caching is disabled, so uploads directory write access is not required.', 'activitypub' ) ); return $result; } - if ( ! \function_exists( 'WP_Filesystem' ) ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - // Check filesystem without modifying global state for other health checks. - $filesystem_works = \WP_Filesystem(); - - if ( ! $filesystem_works ) { - // Clear the broken object that WP_Filesystem() may have left behind. - global $wp_filesystem; - $wp_filesystem = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally clearing broken FTP fallback object. + $upload_dir = \wp_upload_dir(); + if ( ! \wp_is_writable( $upload_dir['basedir'] ) ) { $result['status'] = 'recommended'; - $result['label'] = \__( 'Filesystem access is not available for media caching', 'activitypub' ); + $result['label'] = \__( 'Uploads directory is not writable for media caching', 'activitypub' ); $result['badge']['color'] = 'orange'; $result['description'] = \sprintf( '

%s

%s

', - \__( 'WordPress cannot access the filesystem directly and is falling back to FTP, which is not supported for media caching. 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 enable direct filesystem access, or add define( \'FS_METHOD\', \'direct\' ); to your wp-config.php if your server permissions allow it.', 'activitypub' ) + \__( '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 configure direct filesystem access, you can disable media caching by adding define( \'ACTIVITYPUB_DISABLE_REMOTE_CACHE\', true ); to your wp-config.php. Remote media will then be served from its original URL.', 'activitypub' ) + \__( 'If you cannot make the directory writable, you can disable media caching by adding define( \'ACTIVITYPUB_DISABLE_REMOTE_CACHE\', true ); to your wp-config.php. Remote media will then be served from its original URL.', 'activitypub' ) ); } From 7d5ff1b04a3ecea4154d4534d43a860286f4ca04 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Feb 2026 08:56:36 +0100 Subject: [PATCH 5/6] Use WP_Filesystem_Direct instead of raw PHP functions Instantiate WP_Filesystem_Direct explicitly to get WordPress's own filesystem methods (move, copy, rmdir) without the FTP fallback that WP_Filesystem() triggers. Cleaner than raw rename/copy/rmdir calls and leverages WP_Filesystem_Direct's recursive rmdir. --- includes/cache/class-file.php | 56 ++++++++++++++++++---------------- includes/class-attachments.php | 14 ++++++--- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/includes/cache/class-file.php b/includes/cache/class-file.php index 23e139e429..73fde51270 100644 --- a/includes/cache/class-file.php +++ b/includes/cache/class-file.php @@ -237,9 +237,8 @@ public static function cache( $url, $entity_id, $options = array() ) { $file_name = $hash . '.' . $ext; $file_path = $paths['basedir'] . '/' . $file_name; - // Move file to destination (using rename like WordPress core's _wp_handle_upload). - // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Matching core behavior for uploads directory. - if ( ! \rename( $tmp_file, $file_path ) ) { + // Move file to destination. + if ( ! static::get_filesystem()->move( $tmp_file, $file_path, true ) ) { \wp_delete_file( $tmp_file ); return false; } @@ -285,12 +284,33 @@ public static function invalidate_entity( $entity_id ) { } /** - * Delete a directory and all its contents. + * Get a direct filesystem instance. * - * Uses native PHP functions instead of WP_Filesystem, matching how - * WordPress core handles files in the uploads directory. This avoids - * the FTP fallback that WP_Filesystem() triggers on servers without - * direct filesystem access. + * 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 ); + } + + return $filesystem; + } + + /** + * Delete a directory and all its contents. * * @since unreleased * @@ -303,22 +323,7 @@ public static function delete_directory( $basedir ) { return true; } - $files = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( $basedir, \FilesystemIterator::SKIP_DOTS ), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ( $files as $file ) { - if ( $file->isDir() ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Using native PHP for uploads directory, matching core behavior. - \rmdir( $file->getRealPath() ); - } else { - \wp_delete_file( $file->getRealPath() ); - } - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Using native PHP for uploads directory, matching core behavior. - return \rmdir( $basedir ); + return static::get_filesystem()->rmdir( $basedir, true ); } /** @@ -569,8 +574,7 @@ protected static function validate_mime_type( $file_path ) { $new_path = $file_path . '.' . $expected_ext; } - // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Matching core behavior for uploads directory. - if ( \rename( $file_path, $new_path ) ) { + if ( static::get_filesystem()->move( $file_path, $new_path, true ) ) { return $new_path; } } diff --git a/includes/class-attachments.php b/includes/class-attachments.php index 6c2840ff60..0bfe805ad2 100644 --- a/includes/class-attachments.php +++ b/includes/class-attachments.php @@ -201,6 +201,12 @@ private static function save_attachment( $attachment_data, $post_id, $author_id require_once ABSPATH . 'wp-admin/includes/image.php'; } + // 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'; + + $filesystem = new \WP_Filesystem_Direct( null ); + $is_local = ! preg_match( '#^https?://#i', $attachment_data['url'] ); if ( $is_local ) { @@ -211,15 +217,14 @@ private static function save_attachment( $attachment_data, $post_id, $author_id } // Read local file from disk. - if ( ! \file_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'] ) ); - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy -- Matching core behavior for uploads directory. - \copy( $attachment_data['url'], $tmp_file ); + $filesystem->copy( $attachment_data['url'], $tmp_file, true ); } else { // Validate remote URL before downloading. if ( ! \wp_http_validate_url( $attachment_data['url'] ) ) { @@ -241,8 +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; - // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Matching core behavior for uploads directory. - if ( \rename( $tmp_file, $renamed_tmp ) ) { + if ( $filesystem->move( $tmp_file, $renamed_tmp, true ) ) { $tmp_file = $renamed_tmp; } } From e64f71c352a6ce6eed3265e993312bc647b07ec8 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 25 Feb 2026 08:59:51 +0100 Subject: [PATCH 6/6] Use File::delete_directory() in CLI cache command --- includes/cli/class-cache-command.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/includes/cli/class-cache-command.php b/includes/cli/class-cache-command.php index 03414a5c61..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; /** @@ -206,7 +207,7 @@ private function clear_cache_type( $type ) { // Remove all subdirectories using the cache's native delete_directory helper. foreach ( $subdirs as $subdir ) { - Avatar::delete_directory( $subdir ); + File::delete_directory( $subdir ); } // Also clear avatar URL meta for avatar cache.