Files
WPS3Media/classes/pro/integrations/buddyboss/bboss-item.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

462 lines
12 KiB
PHP

<?php
namespace DeliciousBrains\WP_Offload_Media\Pro\Integrations\BuddyBoss;
use Amazon_S3_And_CloudFront;
use AS3CF_Utils;
use DeliciousBrains\WP_Offload_Media\Items\Item;
use DirectoryIterator;
use WP_Error;
class BBoss_Item extends Item {
/**
* Buddy Boss images have random and unique names, object versioning not needed
*
* @var bool
*/
const CAN_USE_OBJECT_VERSIONING = false;
/**
* Item's summary type name.
*
* @var string
*/
protected static $summary_type_name = 'BuddyBoss';
/**
* Item's summary type.
*
* @var string
*/
protected static $summary_type = 'bboss';
/**
* The sprintf() pattern for creating prefix based on source_id.
*
* @var string
*/
protected static $prefix_pattern = '';
/**
* Buddy Boss images are not managed in yearmonth folders
*
* @var bool
*/
protected static $can_use_yearmonth = false;
/**
* @var string
*/
protected static $folder = '';
/**
* @var bool
*/
protected static $is_cover = false;
/**
* @var bool
*/
protected static $is_group = false;
/**
* @var int
*/
private static $chunk_size = 1000;
/**
* Get a Buddy Boss item object from the database
*
* @param int $source_id
* @param string $object_type
* @param string $image_type
*
* @return BBoss_Item|false
*/
public static function get_buddy_boss_item( $source_id, $object_type, $image_type ) {
/** @var BBoss_Item $class */
$class = static::get_item_class( $object_type, $image_type );
if ( ! empty( $class ) ) {
return $class::get_by_source_id( $source_id );
}
return false;
}
/**
* Get the appropriate Buddy Boss item sub class based on object and image type
*
* @param string $object_type user or group
* @param string $image_type avatar or cover image
*
* @return false|string
*/
public static function get_item_class( $object_type, $image_type ) {
$class_map = array(
'user' => array(
'avatar' => 'BBoss_User_Avatar',
'cover' => 'BBoss_User_Cover',
),
'group' => array(
'avatar' => 'BBoss_Group_Avatar',
'cover' => 'BBoss_Group_Cover',
),
);
if ( isset( $class_map[ $object_type ][ $image_type ] ) ) {
return __NAMESPACE__ . '\\' . $class_map[ $object_type ][ $image_type ];
} else {
return false;
}
}
/**
* Create a new Buddy Boss item from the source id.
*
* @param int $source_id
* @param array $options
*
* @return BBoss_Item|WP_Error
*/
public static function create_from_source_id( $source_id, $options = array() ) {
$file_paths = static::get_local_files( $source_id );
if ( empty( $file_paths ) ) {
return new WP_Error(
'exception',
__( 'No local files found in ' . __FUNCTION__, 'amazon-s3-and-cloudfront' )
);
}
$file_path = static::remove_size_from_filename( $file_paths[ Item::primary_object_key() ] );
$extra_info = array( 'objects' => array() );
foreach ( $file_paths as $key => $path ) {
$extra_info['objects'][ $key ] = array(
'source_file' => wp_basename( $path ),
'is_private' => false,
);
}
return new static(
null,
null,
null,
null,
false,
$source_id,
$file_path,
null,
$extra_info,
self::CAN_USE_OBJECT_VERSIONING
);
}
/**
* Get item's new public prefix path for current settings.
*
* @param bool $use_object_versioning
*
* @return string
*/
public function get_new_item_prefix( $use_object_versioning = true ) {
/** @var Amazon_S3_And_CloudFront $as3cf */
global $as3cf;
// Base prefix from settings
$prefix = $as3cf->get_object_prefix();
$prefix .= AS3CF_Utils::trailingslash_prefix( $as3cf->get_dynamic_prefix( null, static::$can_use_yearmonth ) );
// Buddy Boss specific prefix
$buddy_boss_prefix = sprintf( static::$prefix_pattern, $this->source_id() );
$prefix .= AS3CF_Utils::trailingslash_prefix( $buddy_boss_prefix );
return $prefix;
}
/**
* Return all buddy boss file sizes from the source folder
*
* @param int $source_id
*
* @return array
*/
public static function get_local_files( $source_id ) {
$basedir = bp_core_get_upload_dir( 'upload_path' );
// Get base path and apply filters
if ( static::$is_cover ) {
// Call filters indirectly via bp_attachments_cover_image_upload_dir()
$args = array(
'object_id' => $source_id,
'object_directory' => str_replace( 'buddypress/', '', static::$folder ),
);
$upload_dir = bp_attachments_cover_image_upload_dir( $args );
$image_path = $upload_dir['path'];
} else {
// Call apply_filters directly
$image_path = trailingslashit( $basedir ) . trailingslashit( static::$folder ) . $source_id;
$object_type = static::$is_group ? 'group' : 'user';
$image_path = apply_filters(
'bp_core_avatar_folder_dir',
$image_path,
$source_id,
$object_type,
static::$folder
);
}
$result = array();
if ( ! file_exists( $image_path ) ) {
return $result;
}
$files = new DirectoryIterator( $image_path );
foreach ( $files as $file ) {
if ( $file->isDot() ) {
continue;
}
$base_name = $file->getFilename();
$file_name = substr( $file->getPathname(), strlen( $basedir ) );
$file_name = AS3CF_Utils::unleadingslashit( $file_name );
if ( false !== strpos( $base_name, '-bp-cover-image' ) ) {
$result[ Item::primary_object_key() ] = $file_name;
}
if ( false !== strpos( $base_name, '-bpfull' ) ) {
$result[ Item::primary_object_key() ] = $file_name;
}
if ( false !== strpos( $base_name, '-bpthumb' ) ) {
$result['thumb'] = $file_name;
}
}
return $result;
}
/**
* Buddy Boss specific size removal from URL and convert it to a neutral
* (mock) file name with the correct file extension
*
* @param string $file_name The file
*
* @return string
*/
public static function remove_size_from_filename( $file_name ) {
$path_info = pathinfo( $file_name );
return trailingslashit( $path_info['dirname'] ) . 'bp.' . $path_info['extension'];
}
/**
* Return size name based on the file name
*
* @param string $filename
*
* @return string | null
*/
public function get_object_key_from_filename( $filename ) {
return BuddyBoss::get_object_key_from_filename( $filename );
}
/**
* Get an array of un-managed source_ids in descending order.
*
* While source id isn't strictly unique, it is by source type, which is always used in queries based on called class.
*
* @param int $upper_bound Returned source_ids should be lower than this, use null/0 for no upper bound.
* @param int $limit Maximum number of source_ids to return. Required if not counting.
* @param bool $count Just return a count of matching source_ids? Negates $limit, default false.
*
* @return array|int
*/
public static function get_missing_source_ids( $upper_bound, $limit, $count = false ) {
global $wpdb;
// Bail out with empty values if we are a group class and the groups component is not active
if ( static::$is_group ) {
$active_bp_components = apply_filters( 'bp_active_components', bp_get_option( 'bp-active-components' ) );
if ( empty( $active_bp_components['groups'] ) ) {
return $count ? 0 : array();
}
}
$source_table = $wpdb->prefix . static::$source_table;
$basedir = bp_core_get_upload_dir( 'upload_path' );
$dir = trailingslashit( $basedir ) . static::$folder . '/';
$missing_items = array();
$missing_count = 0;
// Establish an upper bound if needed
if ( empty( $upper_bound ) ) {
$sql = "SELECT max(id) from $source_table";
$max_id = (int) $wpdb->get_var( $sql );
$upper_bound = $max_id + 1;
}
for ( $i = $upper_bound; $i >= 0; $i -= self::$chunk_size ) {
$args = array();
$sql = "
SELECT t.id as ID from $source_table as t
LEFT OUTER JOIN " . static::items_table() . " as i
ON (i.source_id = t.ID AND i.source_type=%s)";
$args[] = static::source_type();
$sql .= ' WHERE i.ID IS NULL AND t.id < %d';
$args[] = $upper_bound;
$sql .= ' ORDER BY t.ID DESC LIMIT %d, %d';
$args[] = $upper_bound - $i;
$args[] = self::$chunk_size;
$sql = $wpdb->prepare( $sql, $args );
$items_without_managed_offload = array_map( 'intval', $wpdb->get_col( $sql ) );
foreach ( $items_without_managed_offload as $item_without_managed_offload ) {
$target = $dir . $item_without_managed_offload;
if ( is_dir( $target ) ) {
if ( $count ) {
$missing_count++;
} else {
$missing_items[] = $item_without_managed_offload;
// If we have enough items, bail out
if ( count( $missing_items ) >= $limit ) {
break 2;
}
}
}
}
}
// Add custom default if available for offload.
if ( ( $count || count( $missing_items ) < $limit ) && is_dir( $dir . '0' ) ) {
if ( ! static::get_by_source_id( 0 ) && ! empty( static::get_local_files( 0 ) ) ) {
if ( $count ) {
$missing_count++;
} else {
$missing_items[] = 0;
}
}
}
if ( $count ) {
return $missing_count;
} else {
return $missing_items;
}
}
/**
* Setter for item's path & original path values
*
* @param string $path
*/
public function set_path( $path ) {
$path = static::remove_size_from_filename( $path );
parent::set_path( $path );
parent::set_original_path( $path );
}
/**
* Get absolute source file paths for offloaded files.
*
* @return array Associative array of object_key => path
*/
public function full_source_paths() {
$basedir = bp_core_get_upload_dir( 'upload_path' );
$item_folder = dirname( $this->source_path() );
$objects = $this->objects();
$sizes = array();
foreach ( $objects as $size => $object ) {
$sizes[ $size ] = trailingslashit( $basedir ) . trailingslashit( $item_folder ) . $object['source_file'];
}
return $sizes;
}
/**
* Get the local URL for an item
*
* @param string|null $object_key
*
* @return string
*/
public function get_local_url( $object_key = null ) {
if ( static::$is_cover ) {
return $this->get_local_cover_url( $object_key );
} else {
return $this->get_local_avatar_url( $object_key );
}
}
/**
* Get the local URL for an avatar item
*
* @param string|null $object_key
*
* @return string
*/
protected function get_local_avatar_url( $object_key = null ) {
$uploads = wp_upload_dir();
$url = trailingslashit( $uploads['baseurl'] );
$url .= $this->source_path( $object_key );
return $url;
}
/**
* Get the local URL for a cover item
*
* @param string|null $object_key
*
* @return string
*/
protected function get_local_cover_url( $object_key = null ) {
$uploads = wp_upload_dir();
$url = trailingslashit( $uploads['baseurl'] );
$url .= $this->source_path( $object_key );
return $url;
}
/**
* Get the prefix pattern
*
* @return string
*/
public static function get_prefix_pattern() {
return static::$prefix_pattern;
}
/**
* Count total, offloaded and not offloaded items on current site.
*
* @return array Keys:
* total: Total media count for site (current blog id)
* offloaded: Count of offloaded media for site (current blog id)
* not_offloaded: Difference between total and offloaded
*/
protected static function get_item_counts(): array {
global $wpdb;
$sql = 'SELECT count(id) FROM ' . static::items_table() . ' WHERE source_type = %s';
$sql = $wpdb->prepare( $sql, static::$source_type );
$offloaded_count = (int) $wpdb->get_var( $sql );
$missing_count = static::get_missing_source_ids( 0, 0, true );
if ( is_array( $missing_count ) ) {
$missing_count = count( $missing_count );
}
return array(
'total' => $offloaded_count + $missing_count,
'offloaded' => $offloaded_count,
'not_offloaded' => $missing_count,
);
}
}