v1.3.0
This commit is contained in:
parent
a97911a978
commit
916d7845be
91 changed files with 6861 additions and 6907 deletions
|
|
@ -526,6 +526,11 @@ class Admin_Page {
|
|||
$sanitized['region'] = isset( $input['region'] ) ? sanitize_text_field( $input['region'] ) : '';
|
||||
$sanitized['domain'] = isset( $input['domain'] ) ? sanitize_text_field( $input['domain'] ) : '';
|
||||
|
||||
// Sub-sizes mode: strict whitelist, default 'all'.
|
||||
$raw_mode = isset( $input['subsizes_mode'] ) ? strtolower( (string) sanitize_text_field( $input['subsizes_mode'] ) ) : '';
|
||||
$allowed_modes = array( 'all', 'thumbnail', 'none' );
|
||||
$sanitized['subsizes_mode'] = in_array( $raw_mode, $allowed_modes, true ) ? $raw_mode : 'all';
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
|
|
@ -565,24 +570,26 @@ class Admin_Page {
|
|||
* @return void
|
||||
*/
|
||||
public function render_settings_page(): void {
|
||||
$host = $this->config->get_host();
|
||||
$access_key = $this->config->get_key();
|
||||
$secret = $this->config->get_secret();
|
||||
$bucket = $this->config->get_bucket();
|
||||
$region = $this->config->get_region();
|
||||
$domain = $this->config->get_domain();
|
||||
$host = $this->config->get_host();
|
||||
$access_key = $this->config->get_key();
|
||||
$secret = $this->config->get_secret();
|
||||
$bucket = $this->config->get_bucket();
|
||||
$region = $this->config->get_region();
|
||||
$domain = $this->config->get_domain();
|
||||
$subsizes_mode = $this->config->get_subsizes_mode();
|
||||
|
||||
$is_configured = $this->config->is_configured();
|
||||
|
||||
// Check which fields are defined in wp-config.php.
|
||||
$host_in_config = $this->config->is_defined_in_wp_config( 'host' );
|
||||
$key_in_config = $this->config->is_defined_in_wp_config( 'key' );
|
||||
$secret_in_config = $this->config->is_defined_in_wp_config( 'secret' );
|
||||
$bucket_in_config = $this->config->is_defined_in_wp_config( 'bucket' );
|
||||
$region_in_config = $this->config->is_defined_in_wp_config( 'region' );
|
||||
$domain_in_config = $this->config->is_defined_in_wp_config( 'domain' );
|
||||
$host_in_config = $this->config->is_defined_in_wp_config( 'host' );
|
||||
$key_in_config = $this->config->is_defined_in_wp_config( 'key' );
|
||||
$secret_in_config = $this->config->is_defined_in_wp_config( 'secret' );
|
||||
$bucket_in_config = $this->config->is_defined_in_wp_config( 'bucket' );
|
||||
$region_in_config = $this->config->is_defined_in_wp_config( 'region' );
|
||||
$domain_in_config = $this->config->is_defined_in_wp_config( 'domain' );
|
||||
$subsizes_mode_in_config = $this->config->is_defined_in_wp_config( 'subsizes_mode' );
|
||||
|
||||
$any_in_config = $host_in_config || $key_in_config || $secret_in_config || $bucket_in_config || $region_in_config || $domain_in_config;
|
||||
$any_in_config = $host_in_config || $key_in_config || $secret_in_config || $bucket_in_config || $region_in_config || $domain_in_config || $subsizes_mode_in_config;
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'iDrivee2 Media Upload Settings', 'idrivee2-media-upload' ); ?></h1>
|
||||
|
|
@ -731,32 +738,80 @@ class Admin_Page {
|
|||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="idrivee2_domain"><?php esc_html_e( 'Custom CDN Domain', 'idrivee2-media-upload' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input
|
||||
type="url"
|
||||
id="idrivee2_domain"
|
||||
name="idrivee2_media_settings[domain]"
|
||||
value="<?php echo esc_attr( $domain ); ?>"
|
||||
class="regular-text"
|
||||
placeholder="https://cdn.example.com"
|
||||
<?php echo $domain_in_config ? 'readonly' : ''; ?>
|
||||
/>
|
||||
<p class="description">
|
||||
<?php
|
||||
if ( $domain_in_config ) {
|
||||
esc_html_e( 'Defined in wp-config.php (IDRIVEE2_MEDIA_DOMAIN)', 'idrivee2-media-upload' );
|
||||
} else {
|
||||
esc_html_e( 'Optional: Custom domain for serving media files.', 'idrivee2-media-upload' );
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="idrivee2_domain"><?php esc_html_e( 'Custom CDN Domain', 'idrivee2-media-upload' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input
|
||||
type="url"
|
||||
id="idrivee2_domain"
|
||||
name="idrivee2_media_settings[domain]"
|
||||
value="<?php echo esc_attr( $domain ); ?>"
|
||||
class="regular-text"
|
||||
placeholder="https://cdn.example.com"
|
||||
<?php echo $domain_in_config ? 'readonly' : ''; ?>
|
||||
/>
|
||||
<p class="description">
|
||||
<?php
|
||||
if ( $domain_in_config ) {
|
||||
esc_html_e( 'Defined in wp-config.php (IDRIVEE2_MEDIA_DOMAIN)', 'idrivee2-media-upload' );
|
||||
} else {
|
||||
esc_html_e( 'Optional: Custom domain for serving media files.', 'idrivee2-media-upload' );
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="idrivee2_subsizes_mode"><?php esc_html_e( 'Image Sub-sizes', 'idrivee2-media-upload' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<select
|
||||
id="idrivee2_subsizes_mode"
|
||||
name="idrivee2_media_settings[subsizes_mode]"
|
||||
<?php echo $subsizes_mode_in_config ? 'disabled' : ''; ?>
|
||||
>
|
||||
<?php
|
||||
$mode_options = array(
|
||||
'all' => __( 'All registered sizes (WordPress default)', 'idrivee2-media-upload' ),
|
||||
'thumbnail' => __( 'Only thumbnail (faster uploads)', 'idrivee2-media-upload' ),
|
||||
'none' => __( 'No sub-sizes (original only)', 'idrivee2-media-upload' ),
|
||||
);
|
||||
foreach ( $mode_options as $value => $label ) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr( $value ),
|
||||
selected( $subsizes_mode, $value, false ),
|
||||
esc_html( $label )
|
||||
);
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
<?php
|
||||
// Disabled selects are not submitted with the form; preserve the
|
||||
// constant-defined value via a hidden input so it is not lost on save.
|
||||
if ( $subsizes_mode_in_config ) {
|
||||
printf(
|
||||
'<input type="hidden" name="idrivee2_media_settings[subsizes_mode]" value="%s" />',
|
||||
esc_attr( $subsizes_mode )
|
||||
);
|
||||
}
|
||||
?>
|
||||
<p class="description">
|
||||
<?php
|
||||
if ( $subsizes_mode_in_config ) {
|
||||
esc_html_e( 'Defined in wp-config.php (IDRIVEE2_MEDIA_SUBSIZES_MODE)', 'idrivee2-media-upload' );
|
||||
} else {
|
||||
esc_html_e( 'Reduces work for large uploads (e.g. camera files). Also disables the -scaled derivative in thumbnail/none modes.', 'idrivee2-media-upload' );
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ( ! $any_in_config ) : ?>
|
||||
|
|
@ -767,7 +822,7 @@ class Admin_Page {
|
|||
</p>
|
||||
<?php
|
||||
// Show submit button only if at least one field can be edited.
|
||||
$can_edit = ! $host_in_config || ! $key_in_config || ! $secret_in_config || ! $bucket_in_config || ! $region_in_config || ! $domain_in_config;
|
||||
$can_edit = ! $host_in_config || ! $key_in_config || ! $secret_in_config || ! $bucket_in_config || ! $region_in_config || ! $domain_in_config || ! $subsizes_mode_in_config;
|
||||
if ( $can_edit ) :
|
||||
?>
|
||||
<?php submit_button( __( 'Save Settings', 'idrivee2-media-upload' ) ); ?>
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration validator and accessor for iDrivee2 constants.
|
||||
*
|
||||
* Validates and provides access to the five required configuration constants
|
||||
* and the optional CDN domain constant. Configuration can come from either
|
||||
* wp-config.php constants (priority) or WordPress options.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
/**
|
||||
* Configuration validator and accessor for iDrivee2 constants.
|
||||
*
|
||||
* Validates and provides access to the five required configuration constants
|
||||
* and the optional CDN domain constant. Configuration can come from either
|
||||
* wp-config.php constants (priority) or WordPress options.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class Config {
|
||||
/**
|
||||
* Required configuration keys.
|
||||
|
|
@ -46,6 +46,25 @@ class Config {
|
|||
*/
|
||||
private const OPTION_NAME = 'idrivee2_media_settings';
|
||||
|
||||
/**
|
||||
* Map of config keys to wp-config.php constant names.
|
||||
*
|
||||
* Centralised so is_defined_in_wp_config() and get_value() cannot drift.
|
||||
*
|
||||
* @since 1.3.0
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const CONSTANT_MAP = array(
|
||||
'host' => 'IDRIVEE2_MEDIA_HOST',
|
||||
'key' => 'IDRIVEE2_MEDIA_KEY',
|
||||
'secret' => 'IDRIVEE2_MEDIA_SECRET',
|
||||
'bucket' => 'IDRIVEE2_MEDIA_BUCKET',
|
||||
'region' => 'IDRIVEE2_MEDIA_REGION',
|
||||
'domain' => 'IDRIVEE2_MEDIA_DOMAIN',
|
||||
'subsizes_mode' => 'IDRIVEE2_MEDIA_SUBSIZES_MODE',
|
||||
);
|
||||
|
||||
/**
|
||||
* Logger instance.
|
||||
*
|
||||
|
|
@ -92,17 +111,8 @@ class Config {
|
|||
* @return bool True if defined in wp-config.php, false otherwise.
|
||||
*/
|
||||
public function is_defined_in_wp_config( string $key ): bool {
|
||||
$constant_map = array(
|
||||
'host' => 'IDRIVEE2_MEDIA_HOST',
|
||||
'key' => 'IDRIVEE2_MEDIA_KEY',
|
||||
'secret' => 'IDRIVEE2_MEDIA_SECRET',
|
||||
'bucket' => 'IDRIVEE2_MEDIA_BUCKET',
|
||||
'region' => 'IDRIVEE2_MEDIA_REGION',
|
||||
'domain' => 'IDRIVEE2_MEDIA_DOMAIN',
|
||||
);
|
||||
|
||||
$constant_name = $constant_map[ $key ] ?? '';
|
||||
return $constant_name && defined( $constant_name );
|
||||
$constant_name = self::CONSTANT_MAP[ $key ] ?? '';
|
||||
return '' !== $constant_name && defined( $constant_name );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -116,18 +126,9 @@ class Config {
|
|||
* @return string The configuration value, or empty string if not set.
|
||||
*/
|
||||
private function get_value( string $key ): string {
|
||||
$constant_map = array(
|
||||
'host' => 'IDRIVEE2_MEDIA_HOST',
|
||||
'key' => 'IDRIVEE2_MEDIA_KEY',
|
||||
'secret' => 'IDRIVEE2_MEDIA_SECRET',
|
||||
'bucket' => 'IDRIVEE2_MEDIA_BUCKET',
|
||||
'region' => 'IDRIVEE2_MEDIA_REGION',
|
||||
'domain' => 'IDRIVEE2_MEDIA_DOMAIN',
|
||||
);
|
||||
|
||||
// Check wp-config.php constant first (highest priority).
|
||||
$constant_name = $constant_map[ $key ] ?? '';
|
||||
if ( $constant_name && defined( $constant_name ) ) {
|
||||
$constant_name = self::CONSTANT_MAP[ $key ] ?? '';
|
||||
if ( '' !== $constant_name && defined( $constant_name ) ) {
|
||||
return (string) constant( $constant_name );
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +217,29 @@ class Config {
|
|||
return ! empty( $this->get_domain() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured sub-sizes mode.
|
||||
*
|
||||
* Controls which image sub-sizes WordPress generates. Returns one of
|
||||
* 'all', 'thumbnail', or 'none'; falls back to 'all' for any value that
|
||||
* is not on the whitelist (including unset/empty).
|
||||
*
|
||||
* @since 1.3.0
|
||||
*
|
||||
* @return string The sub-sizes mode.
|
||||
*/
|
||||
public function get_subsizes_mode(): string {
|
||||
$value = $this->get_value( 'subsizes_mode' );
|
||||
|
||||
$allowed = array(
|
||||
'all',
|
||||
'thumbnail',
|
||||
'none',
|
||||
);
|
||||
|
||||
return in_array( $value, $allowed, true ) ? $value : 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration options in WordPress database.
|
||||
*
|
||||
|
|
@ -239,6 +263,11 @@ class Config {
|
|||
'domain' => isset( $data['domain'] ) ? sanitize_text_field( $data['domain'] ) : '',
|
||||
);
|
||||
|
||||
// Sub-sizes mode: strict whitelist, default 'all'.
|
||||
$raw_mode = isset( $data['subsizes_mode'] ) ? strtolower( (string) sanitize_text_field( $data['subsizes_mode'] ) ) : '';
|
||||
$allowed_modes = array( 'all', 'thumbnail', 'none' );
|
||||
$options['subsizes_mode'] = in_array( $raw_mode, $allowed_modes, true ) ? $raw_mode : 'all';
|
||||
|
||||
// Log configuration changes.
|
||||
if ( $this->logger ) {
|
||||
foreach ( $options as $key => $new_value ) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class Media_Uploader {
|
|||
/**
|
||||
* Attachment IDs currently being uploaded, to prevent re-entrant calls.
|
||||
*
|
||||
* wp_update_post() (used to update the attachment GUID) fires edit_attachment,
|
||||
* Calling wp_update_post() to update the attachment GUID fires edit_attachment,
|
||||
* which would re-trigger upload_attachment_to_idrivee2() causing infinite recursion.
|
||||
*
|
||||
* @var array<int, bool>
|
||||
|
|
@ -145,7 +145,7 @@ class Media_Uploader {
|
|||
$base_path = path_join( $basedir, $meta_file );
|
||||
$meta_sizes = isset( $meta['sizes'] ) && is_array( $meta['sizes'] ) ? $meta['sizes'] : array();
|
||||
|
||||
/** @var array<string, string> $files */
|
||||
/* @var array<string, string> $files */
|
||||
$files = array( 'original' => $base_path );
|
||||
foreach ( $meta_sizes as $size ) {
|
||||
if ( is_array( $size ) && isset( $size['file'] ) && is_string( $size['file'] ) ) {
|
||||
|
|
@ -157,8 +157,8 @@ class Media_Uploader {
|
|||
$client = $this->client_factory->create();
|
||||
$bucket = $this->config->get_bucket();
|
||||
$commands = array();
|
||||
$key_map = array(); // int index -> file metadata
|
||||
$handles = array(); // int index -> resource
|
||||
$key_map = array(); // int index -> file metadata.
|
||||
$handles = array(); // int index -> resource.
|
||||
|
||||
$real_basedir = realpath( $basedir );
|
||||
$idx = 0;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,13 @@ class Plugin {
|
|||
*/
|
||||
private $url_rewriter;
|
||||
|
||||
/**
|
||||
* Sub-size filter.
|
||||
*
|
||||
* @var Subsize_Filter
|
||||
*/
|
||||
private $subsize_filter;
|
||||
|
||||
/**
|
||||
* Plugin file path.
|
||||
*
|
||||
|
|
@ -110,6 +117,7 @@ class Plugin {
|
|||
$this->admin_page = new Admin_Page( $this->config, $this->client_factory, $this->logger, $this->rate_limiter, $plugin_file );
|
||||
$this->media_uploader = new Media_Uploader( $this->config, $this->client_factory, $this->logger );
|
||||
$this->url_rewriter = new URL_Rewriter( $this->config );
|
||||
$this->subsize_filter = new Subsize_Filter( $this->config );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -145,6 +153,7 @@ class Plugin {
|
|||
$this->admin_page->register();
|
||||
$this->media_uploader->register();
|
||||
$this->url_rewriter->register();
|
||||
$this->subsize_filter->register();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
168
includes/class-subsize-filter.php
Normal file
168
includes/class-subsize-filter.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
/**
|
||||
* Sub-size filter for iDrivee2 Media Upload.
|
||||
*
|
||||
* @package iDrivee2Media
|
||||
* @since 1.3.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace iDrivee2Media;
|
||||
|
||||
/**
|
||||
* Prevent direct access to this file.
|
||||
*/
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the image sub-sizes generated by WordPress.
|
||||
*
|
||||
* Reduces the number of sub-sizes generated per uploaded image. Affects both
|
||||
* the WP 7.1 client-side path (the browser reads the registered sub-sizes via
|
||||
* the REST index) and the traditional server-side path
|
||||
* (intermediate_image_sizes_advanced consumes the same list).
|
||||
*
|
||||
* Modes:
|
||||
* - 'all' Default WordPress behavior; all registered sizes are generated.
|
||||
* - 'thumbnail' Only the 'thumbnail' sub-size is generated.
|
||||
* - 'none' No sub-sizes are generated; only the original is kept.
|
||||
*
|
||||
* In 'thumbnail' and 'none' modes the big_image_size_threshold is also
|
||||
* disabled, so WordPress does not create a '-scaled' derivative for images
|
||||
* larger than 2560px.
|
||||
*
|
||||
* @since 1.3.0
|
||||
*/
|
||||
class Subsize_Filter {
|
||||
/**
|
||||
* Mode: generate all registered sub-sizes (default).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const MODE_ALL = 'all';
|
||||
|
||||
/**
|
||||
* Mode: generate only the 'thumbnail' sub-size.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const MODE_THUMBNAIL = 'thumbnail';
|
||||
|
||||
/**
|
||||
* Mode: generate no sub-sizes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const MODE_NONE = 'none';
|
||||
|
||||
/**
|
||||
* Allowed mode values.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ALLOWED_MODES = array(
|
||||
self::MODE_ALL,
|
||||
self::MODE_THUMBNAIL,
|
||||
self::MODE_NONE,
|
||||
);
|
||||
|
||||
/**
|
||||
* Configuration instance.
|
||||
*
|
||||
* @var Config
|
||||
*/
|
||||
private $config;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.3.0
|
||||
*
|
||||
* @param Config $config Configuration instance.
|
||||
*/
|
||||
public function __construct( Config $config ) {
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the WordPress filters.
|
||||
*
|
||||
* Runs at priority 90 so user-supplied filters at the default priority
|
||||
* (10) and at WP core's own guard priority (100) are not pre-empted.
|
||||
*
|
||||
* @since 1.3.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register(): void {
|
||||
add_filter( 'intermediate_image_sizes', array( $this, 'filter_intermediate_image_sizes' ), 90 );
|
||||
add_filter( 'big_image_size_threshold', array( $this, 'filter_big_image_threshold' ), 90, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the list of intermediate image sizes.
|
||||
*
|
||||
* This filter is the upstream source of truth consumed by
|
||||
* wp_get_registered_image_subsizes(), which feeds both the REST index
|
||||
* response (client-side path) and intermediate_image_sizes_advanced
|
||||
* (server-side path).
|
||||
*
|
||||
* @since 1.3.0
|
||||
*
|
||||
* @param array<int, string> $sizes Registered image size names.
|
||||
* @return array<int, string> Filtered size names.
|
||||
*/
|
||||
public function filter_intermediate_image_sizes( array $sizes ): array {
|
||||
$mode = $this->effective_mode();
|
||||
|
||||
if ( self::MODE_ALL === $mode ) {
|
||||
return $sizes;
|
||||
}
|
||||
|
||||
if ( self::MODE_THUMBNAIL === $mode ) {
|
||||
return in_array( 'thumbnail', $sizes, true ) ? array( 'thumbnail' ) : array();
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the big image size threshold.
|
||||
*
|
||||
* Returns 0 in 'thumbnail' and 'none' modes to disable the '-scaled'
|
||||
* derivative that WordPress creates for images larger than the threshold.
|
||||
*
|
||||
* Note: WordPress fires this filter since 5.3. On older versions the
|
||||
* callback is a no-op; the mode still applies via intermediate_image_sizes.
|
||||
*
|
||||
* @since 1.3.0
|
||||
*
|
||||
* @param int $threshold Threshold in pixels. Default 2560.
|
||||
* @param array<int, int> $imagesize Indexed array of width and height.
|
||||
* @param string $file Path to the uploaded file.
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
* @return int Threshold in pixels, or 0 to disable.
|
||||
*/
|
||||
public function filter_big_image_threshold( int $threshold, array $imagesize = array(), string $file = '', int $attachment_id = 0 ): int {
|
||||
if ( self::MODE_ALL === $this->effective_mode() ) {
|
||||
return $threshold;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective mode, defaulting to 'all' for any value that is
|
||||
* not on the whitelist.
|
||||
*
|
||||
* @since 1.3.0
|
||||
*
|
||||
* @return string One of the MODE_* constants.
|
||||
*/
|
||||
private function effective_mode(): string {
|
||||
$mode = $this->config->get_subsizes_mode();
|
||||
return in_array( $mode, self::ALLOWED_MODES, true ) ? $mode : self::MODE_ALL;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue