Files
WPS3Media/classes/pro/integrations/buddyboss/buddyboss.php
Malin 3248cbb029 feat: add S3-compatible storage provider (MinIO, Ceph, R2, etc.)
Adds a new 'S3-Compatible Storage' provider that works with any
S3-API-compatible object storage service, including MinIO, Ceph,
Cloudflare R2, Backblaze B2, and others.

Changes:
- New provider class: classes/providers/storage/s3-compatible-provider.php
  - Provider key: s3compatible
  - Reads user-configured endpoint URL from settings
  - Uses path-style URL access (required by most S3-compatible services)
  - Supports credentials via AS3CF_S3COMPAT_ACCESS_KEY_ID /
    AS3CF_S3COMPAT_SECRET_ACCESS_KEY wp-config.php constants
  - Disables AWS-specific features (Block Public Access, Object Ownership)
- New provider SVG icons (s3compatible.svg, -link.svg, -round.svg)
- Registered provider in main plugin class with endpoint setting support
- Updated StorageProviderSubPage to show endpoint URL input for S3-compatible
- Built pro settings bundle with rollup (Svelte 4.2.19)
- Added package.json and updated rollup.config.mjs for pro-only builds
2026-03-03 12:30:18 +01:00

770 lines
22 KiB
PHP

<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Integrations\BuddyBoss;
use AS3CF_Error;
use AS3CF_Utils;
use DeliciousBrains\WP_Offload_Media\Integrations\Integration;
use DeliciousBrains\WP_Offload_Media\Items\Download_Handler;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DeliciousBrains\WP_Offload_Media\Items\Upload_Handler;
use DeliciousBrains\WP_Offload_Media\Pro\Items\Remove_Provider_Handler;
use Exception;
class BuddyBoss extends Integration {
/**
* Our item types
*
* @var object[]
*/
private $source_types;
/**
* Are we inside a crop operation?
*
* @var bool
*/
private $in_crop = false;
/**
* Did we handle a crop operation?
*
* @var bool
*/
private $did_crop = false;
/**
* Is installed?
*
* @return bool
*/
public static function is_installed(): bool {
global $buddyboss_platform_plugin_file;
if ( class_exists( 'BuddyPress' ) && is_string( $buddyboss_platform_plugin_file ) && ! is_multisite() ) {
return true;
}
return false;
}
/**
* Init integration.
*/
public function init() {
$this->source_types = array(
'bboss-user-avatar' => array(
'class' => BBoss_Item::get_item_class( 'user', 'avatar' ),
),
'bboss-user-cover' => array(
'class' => BBoss_Item::get_item_class( 'user', 'cover' ),
),
'bboss-group-avatar' => array(
'class' => BBoss_Item::get_item_class( 'group', 'avatar' ),
),
'bboss-group-cover' => array(
'class' => BBoss_Item::get_item_class( 'group', 'cover' ),
),
);
// Register our item source types with the global as3cf object.
foreach ( $this->source_types as $key => $source_type ) {
$this->as3cf->register_source_type( $key, $source_type['class'] );
}
// Register our item summary types with the global as3cf object.
$this->as3cf->register_summary_type( BBoss_Item::summary_type(), BBoss_Item::class );
}
/**
* @inheritDoc
*/
public function setup() {
// URL Rewriting.
add_filter( 'bp_core_fetch_avatar_url_check', array( $this, 'fetch_avatar' ), 10, 2 );
add_filter( 'bp_core_fetch_gravatar_url_check', array( $this, 'fetch_default_avatar' ), 99, 2 );
add_filter( 'bb_get_default_custom_upload_profile_avatar', array( $this, 'filter_bb_get_default_custom_upload_profile_avatar' ), 10, 2 );
add_filter( 'bb_get_default_custom_upload_group_avatar', array( $this, 'filter_bb_get_default_custom_upload_group_avatar' ), 10, 2 );
add_filter( 'bp_attachments_pre_get_attachment', array( $this, 'fetch_cover' ), 10, 2 );
add_filter( 'bb_get_default_custom_upload_profile_cover', array( $this, 'filter_bb_get_default_custom_upload_profile_cover' ), 10 );
add_filter( 'bb_get_default_custom_upload_group_cover', array( $this, 'filter_bb_get_default_custom_upload_group_cover' ), 10 );
// Storage handling.
add_action( 'bp_core_pre_avatar_handle_crop', array( $this, 'filter_bp_core_pre_avatar_handle_crop' ), 10, 2 );
add_action( 'xprofile_avatar_uploaded', array( $this, 'avatar_uploaded' ), 10, 3 );
add_action( 'groups_avatar_uploaded', array( $this, 'avatar_uploaded' ), 10, 3 );
add_action( 'xprofile_cover_image_uploaded', array( $this, 'user_cover_uploaded' ), 10, 1 );
add_action( 'groups_cover_image_uploaded', array( $this, 'groups_cover_uploaded' ), 10, 1 );
add_action( 'bp_core_delete_existing_avatar', array( $this, 'delete_existing_avatar' ), 10, 1 );
add_action( 'xprofile_cover_image_deleted', array( $this, 'delete_existing_user_cover' ), 10, 1 );
add_action( 'groups_cover_image_deleted', array( $this, 'delete_existing_group_cover' ), 10, 1 );
add_action( 'deleted_user', array( $this, 'handle_deleted_user' ), 10, 1 );
add_action( 'groups_delete_group', array( $this, 'groups_delete_group' ), 10, 1 );
add_filter( 'bp_attachments_pre_delete_file', array( $this, 'bp_attachments_pre_delete_file' ), 10, 2 );
// Internal filters.
add_filter( 'as3cf_remove_size_from_filename', array( $this, 'remove_size_from_filename' ), 10, 1 );
add_filter( 'as3cf_get_size_string_from_url_for_item_source', array( $this, 'get_size_string_from_url_for_item_source' ), 10, 3 );
add_filter( 'as3cf_get_provider_url_for_item_source', array( $this, 'filter_get_provider_url_for_item_source' ), 10, 3 );
add_filter( 'as3cf_get_local_url_for_item_source', array( $this, 'filter_get_local_url_for_item_source' ), 10, 3 );
add_filter( 'as3cf_strip_image_edit_suffix_and_extension', array( $this, 'filter_strip_image_edit_suffix_and_extension' ), 10, 2 );
}
/**
* If possible, rewrite local avatar URL to remote, possibly using substitute source.
*
* @param string $avatar_url
* @param array $params
* @param null|int $source_id Optional override for the source ID, e.g. default = 0.
*
* @return string
*/
private function rewrite_avatar_url( $avatar_url, $params, $source_id = null ) {
if ( ! $this->as3cf->get_setting( 'serve-from-s3' ) ) {
return $avatar_url;
}
if ( ! isset( $params['item_id'] ) || ! is_numeric( $params['item_id'] ) || empty( $params['object'] ) ) {
return $avatar_url;
}
if ( ! empty( $avatar_url ) && ! $this->as3cf->filter_local->url_needs_replacing( $avatar_url ) ) {
return $avatar_url;
}
if ( ! is_numeric( $source_id ) ) {
$source_id = $params['item_id'];
}
$as3cf_item = BBoss_Item::get_buddy_boss_item( $source_id, $params['object'], 'avatar' );
if ( false !== $as3cf_item ) {
$image_type = ! empty( $params['type'] ) ? $params['type'] : 'full';
$size = 'thumb' === $image_type ? 'thumb' : Item::primary_object_key();
$new_url = $as3cf_item->get_provider_url( $size );
if ( ! empty( $new_url ) && is_string( $new_url ) ) {
return $new_url;
}
}
return $avatar_url;
}
/**
* Returns the avatar's remote URL.
*
* @handles bp_core_fetch_avatar_url_check
*
* @param string $avatar_url
* @param array $params
*
* @return string
*/
public function fetch_avatar( $avatar_url, $params ) {
return $this->rewrite_avatar_url( $avatar_url, $params );
}
/**
* Returns the avatar's remote default URL if gravatar not supplied.
*
* @handles bp_core_fetch_gravatar_url_check
*
* @param string $avatar_url
* @param array $params
*
* @return string
*/
public function fetch_default_avatar( $avatar_url, $params ) {
return $this->rewrite_avatar_url( $avatar_url, $params, 0 );
}
/**
* Filters to change default custom upload avatar image.
*
* @handles bb_get_default_custom_upload_profile_avatar
*
* @param string $custom_upload_profile_avatar Default custom upload avatar URL.
* @param string $size This parameter specifies whether you'd like the 'full' or 'thumb' avatar.
*/
public function filter_bb_get_default_custom_upload_profile_avatar( $custom_upload_profile_avatar, $size ) {
$params = array(
'item_id' => 0,
'object' => 'user',
'type' => $size,
);
return $this->rewrite_avatar_url( $custom_upload_profile_avatar, $params );
}
/**
* Filters to change default custom upload avatar image.
*
* @handles bb_get_default_custom_upload_group_avatar
*
* @param string $custom_upload_group_avatar Default custom upload avatar URL.
* @param string $size This parameter specifies whether you'd like the 'full' or 'thumb' avatar.
*/
public function filter_bb_get_default_custom_upload_group_avatar( $custom_upload_group_avatar, $size ) {
$params = array(
'item_id' => 0,
'object' => 'group',
'type' => $size,
);
return $this->rewrite_avatar_url( $custom_upload_group_avatar, $params );
}
/**
* If possible, rewrite local cover URL to remote, possibly using substitute source.
*
* @param string $cover_url
* @param array $params
* @param null|int $source_id Optional override for the source ID, e.g. default = 0.
*
* @return string
*/
private function rewrite_cover_url( $cover_url, $params, $source_id = null ) {
if ( ! $this->as3cf->get_setting( 'serve-from-s3' ) ) {
return $cover_url;
}
if ( ! isset( $params['item_id'] ) || ! is_numeric( $params['item_id'] ) || empty( $params['object_dir'] ) ) {
return $cover_url;
}
$object_type = $this->object_type_from_dir( $params['object_dir'] );
if ( is_null( $object_type ) ) {
return $cover_url;
}
if ( ! empty( $cover_url ) && ! $this->as3cf->filter_local->url_needs_replacing( $cover_url ) ) {
return $cover_url;
}
if ( ! is_numeric( $source_id ) ) {
$source_id = $params['item_id'];
}
$as3cf_item = BBoss_Item::get_buddy_boss_item( $source_id, $object_type, 'cover' );
if ( false !== $as3cf_item ) {
$new_url = $as3cf_item->get_provider_url( Item::primary_object_key() );
if ( ! empty( $new_url ) && is_string( $new_url ) ) {
// We should not supply remote URL during a delete operation,
// but the delete process will fail if there isn't a local file to delete.
if ( isset( $_POST['action'] ) && 'bp_cover_image_delete' === $_POST['action'] ) {
if ( ! $as3cf_item->exists_locally() ) {
/** @var Download_Handler $download_handler */
$download_handler = $this->as3cf->get_item_handler( Download_Handler::get_item_handler_key_name() );
$download_handler->handle( $as3cf_item );
}
return $cover_url;
}
return $new_url;
}
}
return $cover_url;
}
/**
* Returns the cover's remote URL.
*
* @handles bp_attachments_pre_get_attachment
*
* @param string $cover_url
* @param array $params
*
* @return string
*/
public function fetch_cover( $cover_url, $params ) {
return $this->rewrite_cover_url( $cover_url, $params );
}
/**
* Filters to change default custom upload cover image.
*
* @handles bb_get_default_custom_upload_profile_cover
*
* @param string $value Default custom upload profile cover URL.
*/
public function filter_bb_get_default_custom_upload_profile_cover( $value ) {
$params = array(
'item_id' => 0,
'object_dir' => 'members',
);
return $this->rewrite_cover_url( $value, $params );
}
/**
* Filters default custom upload cover image URL.
*
* @handles bb_get_default_custom_upload_group_cover
*
* @param string $value Default custom upload group cover URL.
*/
public function filter_bb_get_default_custom_upload_group_cover( $value ) {
$params = array(
'item_id' => 0,
'object_dir' => 'groups',
);
return $this->rewrite_cover_url( $value, $params );
}
/**
* Filters whether or not to handle cropping.
*
* But we use it to catch a successful crop so we can offload
* and later supply the correct remote URL.
*
* @handles bp_core_pre_avatar_handle_crop
*
* @param bool $value Whether or not to crop.
* @param array $r Array of parsed arguments for function.
*
* @throws Exception
*/
public function filter_bp_core_pre_avatar_handle_crop( $value, $r ) {
if ( ! function_exists( 'bp_core_avatar_handle_crop' ) ) {
return $value;
}
$this->in_crop = ! $this->in_crop;
if ( $this->in_crop ) {
if ( bp_core_avatar_handle_crop( $r ) ) {
$this->avatar_uploaded( $r['item_id'], 'crop', $r );
$this->did_crop = true;
}
// We handled the crop.
return false;
}
// Don't cancel operation when we call it above.
return $value;
}
/**
* Handle a newly uploaded avatar
*
* @handles xprofile_avatar_uploaded
* @handles groups_avatar_uploaded
*
* @param int $source_id
* @param string $avatar_type
* @param array $params
*
* @throws Exception
*/
public function avatar_uploaded( $source_id, $avatar_type, $params ) {
if ( $this->did_crop ) {
return;
}
if ( ! $this->as3cf->get_setting( 'copy-to-s3' ) ) {
return;
}
if ( empty( $params['object'] ) ) {
return;
}
$object_type = $params['object'];
$image_type = 'avatar';
$as3cf_item = BBoss_Item::get_buddy_boss_item( $source_id, $object_type, $image_type );
if ( false !== $as3cf_item ) {
$this->delete_existing_avatar( array( 'item_id' => $source_id, 'object' => $object_type ) );
}
/** @var BBoss_Item $class */
$class = BBoss_Item::get_item_class( $object_type, $image_type );
$as3cf_item = $class::create_from_source_id( $source_id );
$upload_handler = $this->as3cf->get_item_handler( Upload_Handler::get_item_handler_key_name() );
$upload_result = $upload_handler->handle( $as3cf_item );
// TODO: Should not be needed ...
if ( is_wp_error( $upload_result ) ) {
return;
}
// TODO: ... as this should be redundant.
// TODO: However, when user has offloaded avatar and replaces it,
// TODO: this save is required as handler returns false.
// TODO: As there is a delete above, this should not be the case!
$as3cf_item->save();
}
/**
* Handle when a new user cover image is uploaded
*
* @handles xprofile_cover_image_uploaded
*
* @param int $source_id
*
* @throws Exception
*/
public function user_cover_uploaded( $source_id ) {
$this->cover_uploaded( $source_id, 'user' );
}
/**
* Handle when a new group cover image is uploaded
*
* @handles xprofile_cover_image_uploaded
*
* @param int $source_id
*
* @throws Exception
*/
public function groups_cover_uploaded( $source_id ) {
$this->cover_uploaded( $source_id, 'group' );
}
/**
* Handle a new group or user cover image
*
* @param int $source_id
* @param string $object_type
*
* @throws Exception
*/
private function cover_uploaded( $source_id, $object_type ) {
if ( ! $this->as3cf->get_setting( 'copy-to-s3' ) ) {
return;
}
$as3cf_item = BBoss_Item::get_buddy_boss_item( $source_id, $object_type, 'cover' );
if ( false !== $as3cf_item ) {
$this->delete_existing_cover( $source_id, $object_type );
}
/** @var BBoss_Item $class */
$class = BBoss_Item::get_item_class( $object_type, 'cover' );
$as3cf_item = $class::create_from_source_id( $source_id );
$upload_handler = $this->as3cf->get_item_handler( Upload_Handler::get_item_handler_key_name() );
$upload_handler->handle( $as3cf_item );
}
/**
* Removes a user cover image from the remote bucket
*
* @handles xprofile_cover_image_deleted
*
* @param int $source_id
*/
public function delete_existing_user_cover( $source_id ) {
$this->delete_existing_cover( $source_id, 'user' );
}
/**
* Removes a group cover image from the remote bucket
*
* @handles groups_cover_image_deleted
*
* @param int $source_id
*/
public function delete_existing_group_cover( $source_id ) {
$this->delete_existing_cover( $source_id, 'group' );
}
/**
* Removes a cover image from the remote bucket
*
* @handles bp_core_delete_existing_avatar
*
* @param int $source_id
* @param string $object_type
*/
public function delete_existing_cover( $source_id, $object_type ) {
/** @var BBoss_Item $as3cf_item */
$as3cf_item = BBoss_Item::get_buddy_boss_item( $source_id, $object_type, 'cover' );
if ( ! empty( $as3cf_item ) ) {
$remove_provider = $this->as3cf->get_item_handler( Remove_Provider_Handler::get_item_handler_key_name() );
$remove_provider->handle( $as3cf_item, array( 'verify_exists_on_local' => false ) );
$as3cf_item->delete();
}
}
/**
* Removes avatar and cover from remote bucket when a user is deleted
*
* @handles deleted_user
*
* @param int $user_id
*/
public function handle_deleted_user( $user_id ) {
$args = array( 'item_id' => $user_id, 'object' => 'user' );
$this->delete_existing_avatar( $args );
$this->delete_existing_cover( $user_id, 'user' );
}
/**
* Removes avatar and cover when a group is deleted
*
* @handles groups_delete_group
*
* @param int $group_id
*/
public function groups_delete_group( $group_id ) {
$args = array( 'item_id' => $group_id, 'object' => 'group' );
$this->delete_existing_avatar( $args );
$this->delete_existing_cover( $group_id, 'group' );
}
/**
* Removes an avatar from the remote bucket
*
* @handles bp_core_delete_existing_avatar
*
* @param array $args
*/
public function delete_existing_avatar( $args ) {
if ( ! isset( $args['item_id'] ) || ! is_numeric( $args['item_id'] ) || empty( $args['object'] ) ) {
return;
}
/** @var BBoss_Item $as3cf_item */
$as3cf_item = BBoss_Item::get_buddy_boss_item( $args['item_id'], $args['object'], 'avatar' );
if ( ! empty( $as3cf_item ) ) {
$remove_provider = $this->as3cf->get_item_handler( Remove_Provider_Handler::get_item_handler_key_name() );
$remove_provider->handle( $as3cf_item, array( 'verify_exists_on_local' => false ) );
$as3cf_item->delete();
}
}
/**
* Identifies URLs to avatars and cover images and rewrites the URL to
* the size neutral version.
*
* @handles as3cf_remove_size_from_filename
*
* @param string $url
*
* @return string
*/
public function remove_size_from_filename( $url ) {
$found_match = false;
foreach ( $this->source_types as $source_type ) {
/** @var BBoss_Item $class */
$class = $source_type['class'];
$pattern = sprintf(
'/\/%s\/[0-9a-f]{9,14}-bp(full|thumb|\-cover\-image)\./',
str_replace( '%d', '\d+', preg_quote( $class::get_prefix_pattern(), '/' ) )
);
$match = preg_match( $pattern, $url );
if ( ! empty( $match ) ) {
$found_match = true;
break;
}
}
if ( ! $found_match ) {
return $url;
}
return BBoss_Item::remove_size_from_filename( $url );
}
/**
* Get the size from a URL for the Buddy Boss item types
*
* @handles as3cf_get_size_string_from_url_for_item_source
*
* @param string $size
* @param string $url
* @param array $item_source
*
* @return string
*/
public function get_size_string_from_url_for_item_source( $size, $url, $item_source ) {
if ( ! in_array( $item_source['source_type'], array_keys( $this->source_types ), true ) ) {
return $size;
}
return static::get_object_key_from_filename( $url );
}
/**
* Return size name based on the file name
*
* @param string $filename
*
* @return string | null
*/
public static function get_object_key_from_filename( $filename ) {
$size = Item::primary_object_key();
$filename = preg_replace( '/\?.*/', '', $filename );
$basename = AS3CF_Utils::encode_filename_in_path( wp_basename( $filename ) );
if ( false !== strpos( $basename, '-bpthumb' ) ) {
$size = 'thumb';
}
return $size;
}
/**
* Get the remote URL for a User / Group avatar or cover image
*
* @handles as3cf_get_provider_url_for_item_source
*
* @param string $url Url
* @param array $item_source The item source descriptor array
* @param string $size Name of requested size
*
* @return string
*/
public function filter_get_provider_url_for_item_source( $url, $item_source, $size ) {
if ( Item::is_empty_item_source( $item_source ) ) {
return $url;
}
if ( ! in_array( $item_source['source_type'], array_keys( $this->source_types ), true ) ) {
return $url;
}
/** @var BBoss_Item $class */
$class = ! empty( $this->source_types[ $item_source['source_type'] ] ) ? $this->source_types[ $item_source['source_type'] ]['class'] : false;
if ( false !== $class ) {
if ( empty( $size ) ) {
$size = Item::primary_object_key();
}
$as3cf_item = $class::get_by_source_id( $item_source['id'] );
if ( empty( $as3cf_item ) || ! $as3cf_item->served_by_provider() ) {
return $url;
}
$url = $as3cf_item->get_provider_url( $size );
}
return $url;
}
/**
* Get the local URL for a User / Group avatar or cover image
*
* @handles as3cf_get_local_url_for_item_source
*
* @param string $url Url
* @param array $item_source The item source descriptor array
* @param string $size Name of requested size
*
* @return string
*/
public function filter_get_local_url_for_item_source( $url, $item_source, $size ) {
if ( Item::is_empty_item_source( $item_source ) ) {
return $url;
}
if ( ! in_array( $item_source['source_type'], array_keys( $this->source_types ), true ) ) {
return $url;
}
/** @var BBoss_Item $class */
$class = ! empty( $this->source_types[ $item_source['source_type'] ] ) ? $this->source_types[ $item_source['source_type'] ]['class'] : false;
if ( ! empty( $class ) ) {
if ( empty( $size ) ) {
$size = Item::primary_object_key();
}
$as3cf_item = $class::get_by_source_id( $item_source['id'] );
if ( empty( $as3cf_item ) ) {
return $url;
}
$url = $as3cf_item->get_local_url( $size );
}
return $url;
}
/**
* Remove fake filename ending from a stripped bucket key
*
* @handles as3cf_strip_image_edit_suffix_and_extension
*
* @param string $path
* @param string $source_type
*
* @return string
*/
public function filter_strip_image_edit_suffix_and_extension( $path, $source_type ) {
if ( ! in_array( $source_type, array_keys( $this->source_types ), true ) ) {
return $path;
}
if ( '/bp' === substr( $path, -3 ) ) {
$path = trailingslashit( dirname( $path ) );
}
return $path;
}
/**
* Handle / override Buddy Boss attempt to delete a local file that we have already removed
*
* @handles bp_attachments_pre_delete_file
*
* @param bool $pre
* @param array $args
*
* @return bool
*/
public function bp_attachments_pre_delete_file( $pre, $args ) {
if ( empty( $args['object_dir'] ) || empty( $args['item_id'] ) ) {
return $pre;
}
$object_type = $this->object_type_from_dir( $args['object_dir'] );
if ( is_null( $object_type ) ) {
return $pre;
}
/** @var BBoss_Item $class */
$class = BBoss_Item::get_item_class( $object_type, 'cover' );
$as3cf_item = $class::get_by_source_id( (int) $args['item_id'] );
if ( ! $as3cf_item ) {
return $pre;
}
$source_file = $as3cf_item->full_source_path( Item::primary_object_key() );
if ( file_exists( $source_file ) ) {
return $pre;
}
return false;
}
/**
* Return object_type (user or group) based on object_dir passed in from Buddy Boss
*
* @param string $object_dir
*
* @return string|null
*/
private function object_type_from_dir( $object_dir ) {
switch ( $object_dir ) {
case 'members':
return 'user';
case 'groups':
return 'group';
}
AS3CF_Error::log( 'Unknown object_dir ' . $object_dir );
return null;
}
}