Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/fix-filesystem-graceful-failure
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 43 additions & 32 deletions includes/cache/class-file.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ abstract class File {
*/
private static $finfo = null;


/**
* Get the cache type identifier.
*
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 );
}

/**
Expand Down Expand Up @@ -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;
}
}
Expand Down
80 changes: 2 additions & 78 deletions includes/cache/class-media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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'] );
}
}
18 changes: 7 additions & 11 deletions includes/class-attachments.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );

Expand All @@ -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'] ) ) {
Expand All @@ -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;
}
}
Expand Down
16 changes: 3 additions & 13 deletions includes/cli/class-cache-command.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use Activitypub\Cache\Avatar;
use Activitypub\Cache\Emoji;
use Activitypub\Cache\File;
use Activitypub\Cache\Media;

/**
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions includes/wp-admin/class-health-check.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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(
'<p>%s</p>',
\__( '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(
'<p>%s</p>',
\__( '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(
'<p>%s</p><p>%s</p>',
\__( '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(
'<p>%s</p>',
\__( 'If you cannot make the directory writable, you can disable media caching by adding <code>define( \'ACTIVITYPUB_DISABLE_REMOTE_CACHE\', true );</code> to your <code>wp-config.php</code>. Remote media will then be served from its original URL.', 'activitypub' )
);
}

return $result;
}

/**
* Count outbox items created in the last hour.
*
Expand Down