mirror of
https://github.com/Heretek-AI/heretek-control-core.git
synced 2026-10-12 02:03:55 +00:00
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:
co-authored by
Claude Opus 4.8
parent
6c8b619760
commit
d04cece5f8
@@ -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); }
|
||||
@@ -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 );
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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' ); ?> →</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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user