feat(security): encrypt stock-image API keys at rest + mask in UI

- Add EMCP_Tools_Secret: per-site (AUTH_KEY/SECURE_AUTH_KEY-derived)
  libsodium/OpenSSL encryption with plaintext passthrough on read.
- Store provider keys encrypted; clients decrypt_if_needed() on use, so
  legacy plaintext + constant-provided keys keep working.
- Never render the key back to the form: password field posts empty, an
  empty submit keeps the stored value, a per-field '__clear' checkbox
  removes it, a new value is encrypted. Adds a 'saved' badge.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Mian Shahzad Raza
2026-07-04 03:51:14 +05:00
co-authored by Claude Opus 4.8
parent 6c8b619760
commit d04cece5f8
8 changed files with 840 additions and 14 deletions
+4 -1
View File
@@ -2885,4 +2885,7 @@
.emcp-subtab-actions { display: flex; align-items: center; padding-bottom: 6px; }
.emcp-subtab-save { margin: 0 !important; }
/* [hidden] alone loses to WP's .button { display:inline-block } — force it. */
.emcp-subtab-save[hidden] { display: none !important; }
.emcp-subtab-save[hidden] { display: none !important; }
/* 3rd Party Services: saved-key badge + clear control ----------------------- */
.emcp-service-badge { display: inline-block; margin-left: 8px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; color: #1a7a34; background: #edfaef; border-radius: 3px; padding: 1px 6px; vertical-align: middle; }
.emcp-service-clear { display: inline-flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 12px; color: var(--mcp-gray-600, #50575e); }
+15 -3
View File
@@ -556,7 +556,10 @@ class EMCP_Tools_Admin {
// Stock-image provider API keys (Connection → 3rd Party Services sub-tab)
// — power the stock-image tools (search-images / add-stock-image). All
// three are free keys. Registered in their own group so that sub-tab's
// form saves without touching the server-gate toggles.
// form saves without touching the server-gate toggles. Keys are stored
// encrypted at rest (EMCP_Tools_Secret) and never rendered back to the
// form: the field posts empty when unchanged (we keep the stored value),
// a per-field "__clear" checkbox removes it, and a new value is encrypted.
foreach ( array( EMCP_Tools_Unsplash_Client::OPTION, EMCP_Tools_Pexels_Client::OPTION, EMCP_Tools_Pixabay_Client::OPTION ) as $emcp_stock_option ) {
register_setting(
self::SETTINGS_GROUP_SERVICES,
@@ -564,8 +567,17 @@ class EMCP_Tools_Admin {
array(
'type' => 'string',
'default' => '',
'sanitize_callback' => static function ( $value ) {
return sanitize_text_field( (string) $value );
'sanitize_callback' => static function ( $value ) use ( $emcp_stock_option ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- options.php verifies the settings-group nonce before this runs.
if ( ! empty( $_POST[ $emcp_stock_option . '__clear' ] ) ) {
return '';
}
$value = sanitize_text_field( (string) $value );
if ( '' === $value ) {
// Unchanged (masked) submit — keep the stored value.
return (string) get_option( $emcp_stock_option, '' );
}
return EMCP_Tools_Secret::encrypt( $value );
},
)
);
+24 -10
View File
@@ -395,6 +395,17 @@ SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1</pre>
);
foreach ( $emcp_stock_providers as $emcp_sp ) :
$emcp_sp_const = defined( $emcp_sp['const'] );
// A key is on file when the option is non-empty and not
// constant-overridden. The value itself is NEVER rendered.
$emcp_sp_saved = ! $emcp_sp_const && '' !== (string) get_option( $emcp_sp['option'], '' );
if ( $emcp_sp_const ) {
/* translators: %s: PHP constant name. */
$emcp_sp_placeholder = sprintf( __( 'Set via the %s constant', 'emcp-tools' ), $emcp_sp['const'] );
} elseif ( $emcp_sp_saved ) {
$emcp_sp_placeholder = __( '•••••••••••••• saved — leave blank to keep', 'emcp-tools' );
} else {
$emcp_sp_placeholder = __( 'Paste your API key', 'emcp-tools' );
}
?>
<div class="emcp-service-field">
<div class="emcp-service-field-head">
@@ -403,26 +414,29 @@ SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1</pre>
/* translators: %s: provider name (Unsplash / Pexels / Pixabay). */
echo esc_html( sprintf( __( '%s API key', 'emcp-tools' ), $emcp_sp['label'] ) );
?>
<?php if ( $emcp_sp_saved ) : ?>
<span class="emcp-service-badge"><?php esc_html_e( 'saved', 'emcp-tools' ); ?></span>
<?php endif; ?>
</label>
<a href="<?php echo esc_url( $emcp_sp['url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Get a free key', 'emcp-tools' ); ?> &rarr;</a>
</div>
<input
type="text"
type="password"
id="emcp-tools-<?php echo esc_attr( $emcp_sp['option'] ); ?>"
name="<?php echo esc_attr( $emcp_sp['option'] ); ?>"
value="<?php echo esc_attr( $emcp_sp_const ? '' : (string) get_option( $emcp_sp['option'], '' ) ); ?>"
placeholder="<?php
if ( $emcp_sp_const ) {
/* translators: %s: PHP constant name. */
echo esc_attr( sprintf( __( 'Set via the %s constant', 'emcp-tools' ), $emcp_sp['const'] ) );
} else {
echo esc_attr__( 'Paste your API key', 'emcp-tools' );
}
?>"
value=""
placeholder="<?php echo esc_attr( $emcp_sp_placeholder ); ?>"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
<?php disabled( $emcp_sp_const ); ?>
/>
<?php if ( $emcp_sp_saved ) : ?>
<label class="emcp-service-clear">
<input type="checkbox" name="<?php echo esc_attr( $emcp_sp['option'] . '__clear' ); ?>" value="1" />
<?php esc_html_e( 'Remove saved key', 'emcp-tools' ); ?>
</label>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
+1
View File
@@ -96,6 +96,7 @@ class EMCP_Tools_Bootstrap {
require_once EMCP_TOOLS_DIR . 'includes/abilities/class-template-abilities.php';
require_once EMCP_TOOLS_DIR . 'includes/abilities/class-global-abilities.php';
require_once EMCP_TOOLS_DIR . 'includes/abilities/class-composite-abilities.php';
require_once EMCP_TOOLS_DIR . 'includes/class-secret.php';
require_once EMCP_TOOLS_DIR . 'includes/class-unsplash-client.php';
require_once EMCP_TOOLS_DIR . 'includes/class-pexels-client.php';
require_once EMCP_TOOLS_DIR . 'includes/class-pixabay-client.php';
+205
View File
@@ -0,0 +1,205 @@
<?php
/**
* HTTP client for the Pexels API.
*
* One of the three stock-photo providers behind the stock-image tools (with
* Unsplash + Pixabay). Requires a free API key from https://www.pexels.com/api/,
* read from the `EMCP_TOOLS_PEXELS_API_KEY` constant else the
* `emcp_tools_pexels_api_key` option (EMCP Tools → Connection).
*
* Results are normalized to the shared stock-image field shape.
*
* @package EMCP_Tools
* @since 3.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Pexels API client.
*
* @since 3.1.0
*/
class EMCP_Tools_Pexels_Client {
const API_BASE = 'https://api.pexels.com/v1';
const TIMEOUT = 15;
const OPTION = 'emcp_tools_pexels_api_key';
/**
* @since 3.1.0
* @return string
*/
public static function access_key(): string {
if ( defined( 'EMCP_TOOLS_PEXELS_API_KEY' ) && '' !== (string) EMCP_TOOLS_PEXELS_API_KEY ) {
return (string) EMCP_TOOLS_PEXELS_API_KEY;
}
return EMCP_Tools_Secret::decrypt_if_needed( (string) get_option( self::OPTION, '' ) );
}
/**
* @since 3.1.0
* @return bool
*/
public static function has_key(): bool {
return '' !== trim( self::access_key() );
}
/**
* Search Pexels photos.
*
* @since 3.1.0
* @param array $params See EMCP_Tools_Unsplash_Client::search_images().
* @return array|\WP_Error Normalized `{ total, total_pages, results[] }` or error.
*/
public function search_images( array $params ) {
if ( empty( $params['q'] ) ) {
return new \WP_Error( 'missing_query', __( 'The search query parameter is required.', 'emcp-tools' ) );
}
if ( ! self::has_key() ) {
return new \WP_Error( 'no_api_key', __( 'No Pexels API key is configured. Add one on EMCP Tools → Connection.', 'emcp-tools' ) );
}
$per_page = min( max( absint( $params['page_size'] ?? 5 ), 1 ), 80 );
$query_args = array(
'query' => sanitize_text_field( $params['q'] ),
'per_page' => $per_page,
'page' => max( absint( $params['page'] ?? 1 ), 1 ),
);
$orientation = self::orientation( isset( $params['aspect_ratio'] ) ? (string) $params['aspect_ratio'] : '' );
if ( '' !== $orientation ) {
$query_args['orientation'] = $orientation;
}
$url = add_query_arg( $query_args, self::API_BASE . '/search' );
$response = $this->request( $url );
if ( is_wp_error( $response ) ) {
return $response;
}
$results = array();
if ( ! empty( $response['photos'] ) && is_array( $response['photos'] ) ) {
foreach ( $response['photos'] as $photo ) {
$results[] = self::normalize_photo( $photo );
}
}
$total = intval( $response['total_results'] ?? count( $results ) );
$per_page = max( 1, intval( $response['per_page'] ?? $per_page ) );
return array(
'total' => $total,
'total_pages' => (int) ceil( $total / $per_page ),
'results' => $results,
);
}
/**
* Pexels has no download-tracking endpoint. No-op (kept for a uniform client API).
*
* @since 3.1.0
* @param string $download_location Unused.
*/
public function trigger_download( string $download_location ): void {}
/**
* @since 3.1.0
* @param string $aspect_ratio wide|tall|square.
* @return string landscape|portrait|square|''
*/
private static function orientation( string $aspect_ratio ): string {
switch ( sanitize_key( $aspect_ratio ) ) {
case 'wide':
return 'landscape';
case 'tall':
return 'portrait';
case 'square':
return 'square';
default:
return '';
}
}
/**
* @since 3.1.0
* @param array $p Raw Pexels photo.
* @return array
*/
private static function normalize_photo( array $p ): array {
$src = isset( $p['src'] ) && is_array( $p['src'] ) ? $p['src'] : array();
$name = isset( $p['photographer'] ) ? (string) $p['photographer'] : '';
return array(
'id' => isset( $p['id'] ) ? (string) $p['id'] : '',
'title' => isset( $p['alt'] ) ? (string) $p['alt'] : '',
'url' => (string) ( $src['large2x'] ?? $src['large'] ?? $src['original'] ?? '' ),
'thumbnail' => (string) ( $src['medium'] ?? $src['small'] ?? $src['tiny'] ?? '' ),
'width' => intval( $p['width'] ?? 0 ),
'height' => intval( $p['height'] ?? 0 ),
'creator' => $name,
'creator_url' => isset( $p['photographer_url'] ) ? (string) $p['photographer_url'] : '',
'license' => 'Pexels License',
'license_url' => 'https://www.pexels.com/license/',
'attribution' => '' !== $name ? sprintf( 'Photo by %s on Pexels', $name ) : 'Photo on Pexels',
'source' => 'pexels',
'foreign_landing_url' => isset( $p['url'] ) ? (string) $p['url'] : '',
'download_location' => '',
);
}
/**
* @since 3.1.0
* @param string $url Full request URL.
* @return array|\WP_Error
*/
private function request( string $url ) {
$response = wp_remote_get(
$url,
array(
'timeout' => self::TIMEOUT,
'user-agent' => 'Elementor-MCP/' . EMCP_TOOLS_VERSION . ' (WordPress/' . get_bloginfo( 'version' ) . ')',
'headers' => array(
'Accept' => 'application/json',
'Authorization' => self::access_key(),
),
)
);
if ( is_wp_error( $response ) ) {
return new \WP_Error(
'api_request_failed',
sprintf(
/* translators: %s: error message */
__( 'Pexels API request failed: %s', 'emcp-tools' ),
$response->get_error_message()
)
);
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 401 === $code || 403 === $code ) {
return new \WP_Error( 'invalid_key', __( 'Pexels rejected the API key. Check it on EMCP Tools → Connection.', 'emcp-tools' ) );
}
if ( 429 === $code ) {
return new \WP_Error( 'rate_limited', __( 'Pexels rate limit reached (200 requests/hour on the free tier). Try again later.', 'emcp-tools' ) );
}
if ( $code < 200 || $code >= 300 ) {
return new \WP_Error(
'api_error',
sprintf(
/* translators: %d: HTTP status code */
__( 'Pexels API returned HTTP %d.', 'emcp-tools' ),
$code
)
);
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new \WP_Error( 'json_parse_error', __( 'Failed to parse the Pexels API response.', 'emcp-tools' ) );
}
return $data;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
/**
* HTTP client for the Pixabay API.
*
* One of the three stock-photo providers behind the stock-image tools (with
* Unsplash + Pexels). Requires a free API key from https://pixabay.com/api/docs/,
* read from the `EMCP_TOOLS_PIXABAY_API_KEY` constant else the
* `emcp_tools_pixabay_api_key` option (EMCP Tools → Connection).
*
* Results are normalized to the shared stock-image field shape. Pixabay's terms
* require downloading/caching images rather than hotlinking — the sideload step
* satisfies that.
*
* @package EMCP_Tools
* @since 3.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Pixabay API client.
*
* @since 3.1.0
*/
class EMCP_Tools_Pixabay_Client {
const API_BASE = 'https://pixabay.com/api/';
const TIMEOUT = 15;
const OPTION = 'emcp_tools_pixabay_api_key';
/**
* @since 3.1.0
* @return string
*/
public static function access_key(): string {
if ( defined( 'EMCP_TOOLS_PIXABAY_API_KEY' ) && '' !== (string) EMCP_TOOLS_PIXABAY_API_KEY ) {
return (string) EMCP_TOOLS_PIXABAY_API_KEY;
}
return EMCP_Tools_Secret::decrypt_if_needed( (string) get_option( self::OPTION, '' ) );
}
/**
* @since 3.1.0
* @return bool
*/
public static function has_key(): bool {
return '' !== trim( self::access_key() );
}
/**
* Search Pixabay photos.
*
* @since 3.1.0
* @param array $params See EMCP_Tools_Unsplash_Client::search_images().
* @return array|\WP_Error Normalized `{ total, total_pages, results[] }` or error.
*/
public function search_images( array $params ) {
if ( empty( $params['q'] ) ) {
return new \WP_Error( 'missing_query', __( 'The search query parameter is required.', 'emcp-tools' ) );
}
if ( ! self::has_key() ) {
return new \WP_Error( 'no_api_key', __( 'No Pixabay API key is configured. Add one on EMCP Tools → Connection.', 'emcp-tools' ) );
}
// Pixabay requires per_page between 3 and 200.
$per_page = min( max( absint( $params['page_size'] ?? 5 ), 3 ), 200 );
$query_args = array(
'key' => self::access_key(),
'q' => mb_substr( sanitize_text_field( $params['q'] ), 0, 100 ),
'image_type' => 'photo',
'safesearch' => 'true',
'per_page' => $per_page,
'page' => max( absint( $params['page'] ?? 1 ), 1 ),
);
$orientation = self::orientation( isset( $params['aspect_ratio'] ) ? (string) $params['aspect_ratio'] : '' );
if ( '' !== $orientation ) {
$query_args['orientation'] = $orientation;
}
$url = add_query_arg( $query_args, self::API_BASE );
$response = $this->request( $url );
if ( is_wp_error( $response ) ) {
return $response;
}
$results = array();
if ( ! empty( $response['hits'] ) && is_array( $response['hits'] ) ) {
foreach ( $response['hits'] as $hit ) {
$results[] = self::normalize_photo( $hit );
}
}
$total = intval( $response['totalHits'] ?? count( $results ) );
return array(
'total' => $total,
'total_pages' => (int) ceil( $total / max( 1, $per_page ) ),
'results' => $results,
);
}
/**
* Pixabay has no download-tracking endpoint. No-op (uniform client API).
*
* @since 3.1.0
* @param string $download_location Unused.
*/
public function trigger_download( string $download_location ): void {}
/**
* @since 3.1.0
* @param string $aspect_ratio wide|tall|square.
* @return string horizontal|vertical|'' (Pixabay has no square orientation).
*/
private static function orientation( string $aspect_ratio ): string {
switch ( sanitize_key( $aspect_ratio ) ) {
case 'wide':
return 'horizontal';
case 'tall':
return 'vertical';
default:
return '';
}
}
/**
* @since 3.1.0
* @param array $h Raw Pixabay hit.
* @return array
*/
private static function normalize_photo( array $h ): array {
$user = isset( $h['user'] ) ? (string) $h['user'] : '';
$user_id = isset( $h['user_id'] ) ? (int) $h['user_id'] : 0;
$tags = isset( $h['tags'] ) ? (string) $h['tags'] : '';
return array(
'id' => isset( $h['id'] ) ? (string) $h['id'] : '',
'title' => $tags,
'url' => (string) ( $h['largeImageURL'] ?? $h['webformatURL'] ?? '' ),
'thumbnail' => (string) ( $h['webformatURL'] ?? $h['previewURL'] ?? '' ),
'width' => intval( $h['imageWidth'] ?? 0 ),
'height' => intval( $h['imageHeight'] ?? 0 ),
'creator' => $user,
'creator_url' => ( '' !== $user && $user_id ) ? sprintf( 'https://pixabay.com/users/%s-%d/', rawurlencode( $user ), $user_id ) : '',
'license' => 'Pixabay Content License',
'license_url' => 'https://pixabay.com/service/license-summary/',
'attribution' => '' !== $user ? sprintf( 'Image by %s on Pixabay', $user ) : 'Image on Pixabay',
'source' => 'pixabay',
'foreign_landing_url' => isset( $h['pageURL'] ) ? (string) $h['pageURL'] : '',
'download_location' => '',
);
}
/**
* @since 3.1.0
* @param string $url Full request URL (includes the key).
* @return array|\WP_Error
*/
private function request( string $url ) {
$response = wp_remote_get(
$url,
array(
'timeout' => self::TIMEOUT,
'user-agent' => 'Elementor-MCP/' . EMCP_TOOLS_VERSION . ' (WordPress/' . get_bloginfo( 'version' ) . ')',
'headers' => array( 'Accept' => 'application/json' ),
)
);
if ( is_wp_error( $response ) ) {
return new \WP_Error(
'api_request_failed',
sprintf(
/* translators: %s: error message */
__( 'Pixabay API request failed: %s', 'emcp-tools' ),
$response->get_error_message()
)
);
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 400 === $code || 401 === $code ) {
// Pixabay returns 400 for a bad/missing key or invalid params.
return new \WP_Error( 'invalid_key', __( 'Pixabay rejected the request — check the API key on EMCP Tools → Connection.', 'emcp-tools' ) );
}
if ( 429 === $code ) {
return new \WP_Error( 'rate_limited', __( 'Pixabay rate limit reached (100 requests/minute). Try again shortly.', 'emcp-tools' ) );
}
if ( $code < 200 || $code >= 300 ) {
return new \WP_Error(
'api_error',
sprintf(
/* translators: %d: HTTP status code */
__( 'Pixabay API returned HTTP %d.', 'emcp-tools' ),
$code
)
);
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new \WP_Error( 'json_parse_error', __( 'Failed to parse the Pixabay API response.', 'emcp-tools' ) );
}
return $data;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
/**
* Symmetric encryption for at-rest secrets (e.g. third-party API keys).
*
* Keys are derived per-site from the WordPress AUTH_KEY / SECURE_AUTH_KEY salts,
* so a database dump alone never discloses a stored secret. Values are tagged
* with a version prefix so plaintext (legacy / constant-provided) values are
* transparently passed through on read.
*
* Prefers libsodium (bundled in PHP 7.2+), falling back to OpenSSL AES-256-GCM.
*
* @package EMCP_Tools
* @since 3.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Encrypt / decrypt short secrets at rest.
*
* @since 3.1.0
*/
class EMCP_Tools_Secret {
/** Token prefix marking an EMCP-encrypted value. */
const PREFIX = 'emcps1:';
/**
* The 32-byte site-specific encryption key.
*
* @return string Raw 32-byte key.
*/
private static function key(): string {
$material = ( defined( 'AUTH_KEY' ) ? (string) AUTH_KEY : '' )
. ( defined( 'SECURE_AUTH_KEY' ) ? (string) SECURE_AUTH_KEY : '' )
. 'emcp-tools-secret-v1';
if ( function_exists( 'sodium_crypto_generichash' ) ) {
return sodium_crypto_generichash( $material, '', 32 );
}
return hash( 'sha256', $material, true );
}
/**
* Encrypt a plaintext secret. Empty input returns '' (nothing to store).
*
* @param string $plain Plaintext secret.
* @return string Opaque token (or plaintext if no crypto backend exists).
*/
public static function encrypt( string $plain ): string {
if ( '' === $plain ) {
return '';
}
$key = self::key();
if ( function_exists( 'sodium_crypto_secretbox' ) ) {
$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
$cipher = sodium_crypto_secretbox( $plain, $nonce, $key );
return self::PREFIX . 's:' . base64_encode( $nonce . $cipher );
}
if ( function_exists( 'openssl_encrypt' ) ) {
$iv = random_bytes( 12 );
$tag = '';
$cipher = openssl_encrypt( $plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag );
if ( false !== $cipher ) {
return self::PREFIX . 'o:' . base64_encode( $iv . $tag . $cipher );
}
}
// No crypto backend available — store as-is rather than lose the key.
return $plain;
}
/**
* Decrypt a token produced by encrypt(). A non-token (plaintext) value is
* returned unchanged so legacy/constant values keep working.
*
* @param string $token Stored value.
* @return string Plaintext, or '' when a real token fails to decrypt.
*/
public static function decrypt( string $token ): string {
if ( ! self::is_encrypted( $token ) ) {
return $token;
}
$body = substr( $token, strlen( self::PREFIX ) );
$algo = substr( $body, 0, 2 );
$data = base64_decode( substr( $body, 2 ), true );
if ( false === $data || '' === $data ) {
return '';
}
$key = self::key();
if ( 's:' === $algo && function_exists( 'sodium_crypto_secretbox_open' ) ) {
$nb = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
if ( strlen( $data ) <= $nb ) {
return '';
}
$plain = sodium_crypto_secretbox_open( substr( $data, $nb ), substr( $data, 0, $nb ), $key );
return is_string( $plain ) ? $plain : '';
}
if ( 'o:' === $algo && function_exists( 'openssl_decrypt' ) ) {
if ( strlen( $data ) <= 28 ) {
return '';
}
$plain = openssl_decrypt( substr( $data, 28 ), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, substr( $data, 0, 12 ), substr( $data, 12, 16 ) );
return is_string( $plain ) ? $plain : '';
}
return '';
}
/**
* @param string $value Stored value.
* @return bool Whether the value is an EMCP-encrypted token.
*/
public static function is_encrypted( string $value ): bool {
return 0 === strncmp( $value, self::PREFIX, strlen( self::PREFIX ) );
}
/**
* Decrypt when the value is a token; otherwise return it unchanged.
*
* @param string $value Stored value (encrypted token or plaintext).
* @return string Plaintext.
*/
public static function decrypt_if_needed( string $value ): string {
return self::is_encrypted( $value ) ? self::decrypt( $value ) : $value;
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
/**
* HTTP client for the Unsplash API.
*
* Wraps WordPress HTTP API calls to Unsplash's photo search. Unlike the old
* Openverse client, Unsplash requires an Access Key (free — register an app at
* https://unsplash.com/developers). The key is read from the
* `EMCP_TOOLS_UNSPLASH_ACCESS_KEY` constant, else the `emcp_tools_unsplash_access_key`
* option (set on EMCP Tools → Connection).
*
* Results are normalized to the same shape the stock-image abilities consumed
* from Openverse, so the tools' output stays stable.
*
* @package EMCP_Tools
* @since 3.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Unsplash API client.
*
* @since 3.1.0
*/
class EMCP_Tools_Unsplash_Client {
const API_BASE = 'https://api.unsplash.com';
const TIMEOUT = 15;
const OPTION = 'emcp_tools_unsplash_access_key';
/**
* The configured Unsplash Access Key (constant wins over the option).
*
* @since 3.1.0
* @return string
*/
public static function access_key(): string {
if ( defined( 'EMCP_TOOLS_UNSPLASH_ACCESS_KEY' ) && '' !== (string) EMCP_TOOLS_UNSPLASH_ACCESS_KEY ) {
return (string) EMCP_TOOLS_UNSPLASH_ACCESS_KEY;
}
return EMCP_Tools_Secret::decrypt_if_needed( (string) get_option( self::OPTION, '' ) );
}
/**
* @since 3.1.0
* @return bool
*/
public static function has_key(): bool {
return '' !== trim( self::access_key() );
}
/**
* Search Unsplash photos.
*
* @since 3.1.0
* @param array $params {
* @type string $q Search query (required).
* @type int $page Page number (default 1).
* @type int $page_size Results per page (default 5, max 30).
* @type string $aspect_ratio wide|tall|square → Unsplash orientation.
* @type string $color Optional Unsplash color filter.
* @type string $order_by relevant|latest (default relevant).
* }
* @return array|\WP_Error Normalized `{ total, total_pages, results[] }` or error.
*/
public function search_images( array $params ) {
if ( empty( $params['q'] ) ) {
return new \WP_Error( 'missing_query', __( 'The search query parameter is required.', 'emcp-tools' ) );
}
if ( ! self::has_key() ) {
return new \WP_Error(
'no_api_key',
__( 'No Unsplash Access Key is configured. Add one on EMCP Tools → Connection (get a free key at https://unsplash.com/developers).', 'emcp-tools' )
);
}
$query_args = array(
'query' => sanitize_text_field( $params['q'] ),
'per_page' => min( max( absint( $params['page_size'] ?? 5 ), 1 ), 30 ),
'page' => max( absint( $params['page'] ?? 1 ), 1 ),
);
$orientation = self::orientation( isset( $params['aspect_ratio'] ) ? (string) $params['aspect_ratio'] : '' );
if ( '' !== $orientation ) {
$query_args['orientation'] = $orientation;
}
if ( ! empty( $params['color'] ) ) {
$query_args['color'] = sanitize_key( $params['color'] );
}
$order = isset( $params['order_by'] ) ? sanitize_key( $params['order_by'] ) : '';
if ( 'latest' === $order ) {
$query_args['order_by'] = 'latest';
}
$url = add_query_arg( $query_args, self::API_BASE . '/search/photos' );
$response = $this->request( $url );
if ( is_wp_error( $response ) ) {
return $response;
}
$results = array();
if ( ! empty( $response['results'] ) && is_array( $response['results'] ) ) {
foreach ( $response['results'] as $photo ) {
$results[] = self::normalize_photo( $photo );
}
}
return array(
'total' => intval( $response['total'] ?? count( $results ) ),
'total_pages' => intval( $response['total_pages'] ?? 1 ),
'results' => $results,
);
}
/**
* Fire Unsplash's download-tracking endpoint for a chosen photo. Required by the
* Unsplash API guidelines whenever a photo is downloaded/used. Best-effort — the
* result is ignored; failures never block the sideload.
*
* @since 3.1.0
* @param string $download_location The photo's `links.download_location` URL.
*/
public function trigger_download( string $download_location ): void {
$download_location = esc_url_raw( $download_location );
if ( '' === $download_location || 0 !== strpos( $download_location, self::API_BASE ) ) {
return; // Only ever call Unsplash's own endpoint.
}
wp_remote_get(
$download_location,
array(
'timeout' => self::TIMEOUT,
'headers' => array( 'Authorization' => 'Client-ID ' . self::access_key() ),
)
);
}
/**
* Map our aspect_ratio vocabulary to Unsplash's `orientation`.
*
* @since 3.1.0
* @param string $aspect_ratio wide|tall|square (anything else → '').
* @return string landscape|portrait|squarish|''
*/
private static function orientation( string $aspect_ratio ): string {
switch ( sanitize_key( $aspect_ratio ) ) {
case 'wide':
return 'landscape';
case 'tall':
return 'portrait';
case 'square':
return 'squarish';
default:
return '';
}
}
/**
* Normalize an Unsplash photo to the stock-image tools' stable field shape.
*
* @since 3.1.0
* @param array $p Raw Unsplash photo.
* @return array
*/
private static function normalize_photo( array $p ): array {
$urls = isset( $p['urls'] ) && is_array( $p['urls'] ) ? $p['urls'] : array();
$links = isset( $p['links'] ) && is_array( $p['links'] ) ? $p['links'] : array();
$user = isset( $p['user'] ) && is_array( $p['user'] ) ? $p['user'] : array();
$u_link = isset( $user['links']['html'] ) ? (string) $user['links']['html'] : '';
$name = isset( $user['name'] ) ? (string) $user['name'] : '';
$ref = '?utm_source=emcp_tools&utm_medium=referral'; // Unsplash attribution guideline.
return array(
'id' => isset( $p['id'] ) ? (string) $p['id'] : '',
'title' => (string) ( $p['description'] ?? $p['alt_description'] ?? '' ),
// `url` is what gets sideloaded — 'regular' (~1080w) is the web-appropriate default.
'url' => (string) ( $urls['regular'] ?? $urls['full'] ?? $urls['raw'] ?? '' ),
'thumbnail' => (string) ( $urls['small'] ?? $urls['thumb'] ?? '' ),
'width' => intval( $p['width'] ?? 0 ),
'height' => intval( $p['height'] ?? 0 ),
'creator' => $name,
'creator_url' => '' !== $u_link ? $u_link . $ref : '',
'license' => 'Unsplash License',
'license_url' => 'https://unsplash.com/license',
'attribution' => '' !== $name ? sprintf( 'Photo by %s on Unsplash', $name ) : 'Photo on Unsplash',
'source' => 'unsplash',
'foreign_landing_url' => isset( $links['html'] ) ? (string) $links['html'] : '',
// Not surfaced in the tool output schema; used internally to fire the
// Unsplash download trigger when the photo is sideloaded.
'download_location' => isset( $links['download_location'] ) ? (string) $links['download_location'] : '',
);
}
/**
* GET the Unsplash API with the configured key; decode + map errors.
*
* @since 3.1.0
* @param string $url Full request URL.
* @return array|\WP_Error
*/
private function request( string $url ) {
$response = wp_remote_get(
$url,
array(
'timeout' => self::TIMEOUT,
'user-agent' => 'Elementor-MCP/' . EMCP_TOOLS_VERSION . ' (WordPress/' . get_bloginfo( 'version' ) . ')',
'headers' => array(
'Accept-Version' => 'v1',
'Authorization' => 'Client-ID ' . self::access_key(),
),
)
);
if ( is_wp_error( $response ) ) {
return new \WP_Error(
'api_request_failed',
sprintf(
/* translators: %s: error message */
__( 'Unsplash API request failed: %s', 'emcp-tools' ),
$response->get_error_message()
)
);
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 401 === $code ) {
return new \WP_Error( 'invalid_key', __( 'Unsplash rejected the Access Key. Check it on EMCP Tools → Connection.', 'emcp-tools' ) );
}
if ( 403 === $code ) {
return new \WP_Error( 'rate_limited', __( 'Unsplash rate limit reached (demo apps allow 50 requests/hour). Try again later or request production access.', 'emcp-tools' ) );
}
if ( $code < 200 || $code >= 300 ) {
return new \WP_Error(
'api_error',
sprintf(
/* translators: %d: HTTP status code */
__( 'Unsplash API returned HTTP %d.', 'emcp-tools' ),
$code
)
);
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return new \WP_Error( 'json_parse_error', __( 'Failed to parse the Unsplash API response.', 'emcp-tools' ) );
}
return $data;
}
}