*/ public const DEFAULT_MODELS = array( 'gemini' => 'gemini-3.1-flash-lite', 'mistral' => 'mistral-small-latest', 'openai' => 'gpt-4o-mini', 'anthropic' => 'claude-haiku-4-5-20251001', ); /** * Models the plugin currently supports, per provider. Single source of * truth: used to validate the saved model on read (so a retired ID left by * an older plugin version falls back to the default instead of calling a * removed model), to sanitise the Settings selection, and to validate the * Test request. Keep the Settings dropdowns in sync with this list. * * @var array> */ public const ALLOWED_MODELS = array( 'gemini' => array( 'gemini-3.5-flash', 'gemini-3.1-flash-lite' ), 'mistral' => array( 'mistral-small-latest', 'mistral-medium-latest' ), 'openai' => array( 'gpt-4o-mini', 'gpt-4o' ), 'anthropic' => array( 'claude-haiku-4-5-20251001', 'claude-sonnet-4-6' ), ); /** * Human-readable provider name for notices and labels. Single source of * truth for all callers. These are product names, so they are spelled out * rather than translated - ucfirst() would render "openai" as the * incorrect "Openai". * * @param string $provider Provider slug. */ public static function provider_label( string $provider ): string { $labels = array( 'gemini' => 'Google Gemini', 'mistral' => 'Mistral', 'openai' => 'OpenAI', 'anthropic' => 'Anthropic Claude', ); return $labels[ $provider ] ?? ucfirst( $provider ); } /** * Constructor - registers REST route. */ public function __construct() { add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); add_action( 'add_attachment', array( $this, 'maybe_auto_generate_on_upload' ) ); add_action( 'add_attachment', array( __CLASS__, 'invalidate_alt_count_cache' ), 10, 0 ); add_action( 'delete_attachment', array( __CLASS__, 'invalidate_alt_count_cache' ), 10, 0 ); // Keep the dashboard "Alt text coverage" count fresh no matter who // changes an attachment through WordPress. Attachment lifecycle hooks // catch additions/removals; the meta hooks below catch alt text, file, // and generated-metadata changes made by core or third-party plugins. add_action( 'added_post_meta', array( $this, 'maybe_invalidate_alt_cache_on_meta' ), 10, 3 ); add_action( 'updated_post_meta', array( $this, 'maybe_invalidate_alt_cache_on_meta' ), 10, 3 ); add_action( 'deleted_post_meta', array( $this, 'maybe_invalidate_alt_cache_on_meta' ), 10, 3 ); // Inject saved alt text into rendered HTML on the fly. Without this // the AI alt generator only helps new uploads - existing posts keep // serving tags without alt because WordPress copies the alt // once at insert time and never refreshes it. The DB is never // touched. Hooks below cover the four places tags actually // come out of WordPress in 2026: // * the_content - post / page body (classic + Gutenberg) // * widget_text - classic Text / Custom HTML widgets // * widget_block_content - block-based widgets (WP 5.8+) // * render_block - every block render (catches FSE // template parts: header.html / // footer.html in block themes) // * wp_get_attachment_image_attributes - core helper used by // featured images, galleries, the // image block, get_custom_logo() and // most plugins/themes // All four content filters reuse the same single-string rewriter // (early-returns when there is no `generate_alt_text( $image_url ); if ( is_wp_error( $alt ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 'AcceGuru auto-alt failed for attachment %d: %s', $attachment_id, $alt->get_error_message() ) ); } return; } if ( '' === $alt ) { return; } update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); self::invalidate_alt_count_cache(); } // ------------------------------------------------------------------------- // Runtime alt injection // // Closes the gap left by WordPress: when you insert an image into a post, // WP copies whatever alt text the attachment had AT THAT MOMENT into the // post HTML and never refreshes it. If you later add alt to the // attachment (manually or via the AI generator), existing posts stay // broken. These two filters look up the attachment's current alt at // render time and inject it into any that ships without one, so // the alt the user saved actually reaches visitors and screen readers. // ------------------------------------------------------------------------- /** * Per-request cache for the URL -> alt lookups inside * inject_alt_into_content(). Avoids repeating attachment_url_to_postid * (which does a DB query) for the same image used multiple times on * one page (logos, repeating gallery thumbs, etc.). * * @var array */ private array $alt_url_cache = array(); /** * Filter for the_content - walks every tag in the post HTML and, * for any without an alt attribute, looks up the matching attachment by * its wp-image-N class or src URL and injects its saved alt text. Alt text * the author actually wrote is never touched; an empty alt is filled, * because WordPress writes alt="" into the markup at insert time whenever * the alt field was empty then - it is the editor's placeholder, not a * decision. A genuinely decorative image is safe: nothing is injected * unless the Media Library holds alt text for that attachment, and a * decorative image has none. Attributes are read with * WP_HTML_Tag_Processor, not regex, so the word "alt" or a "wp-image-N" * token inside another attribute's value cannot cause a false match. * * @param string $content Post HTML. */ public function inject_alt_into_content( string $content ): string { if ( '' === $content || false === stripos( $content, 'next_tag( 'img' ) ) { // Leave alt the author wrote. get_attribute() returns a string for // alt="...", '' for alt="", true for a bare `alt`, and null when the // attribute is absent - only a non-blank string is a real decision, // so the other three get filled. $existing = $processor->get_attribute( 'alt' ); if ( is_string( $existing ) && '' !== trim( $existing ) ) { continue; } // Fast path: WordPress tags inserted images with class // `wp-image-NNN`. Read the id from the class attribute only, so a // stray "wp-image-N" living in some other attribute (title, data-*) // cannot borrow the wrong attachment's alt. $alt = ''; $class = (string) $processor->get_attribute( 'class' ); if ( preg_match( '/\bwp-image-(\d+)\b/', $class, $cls_match ) ) { $alt = trim( (string) get_post_meta( (int) $cls_match[1], '_wp_attachment_image_alt', true ) ); } // Fallback: resolve the attachment from the src URL. if ( '' === $alt ) { $src = (string) $processor->get_attribute( 'src' ); if ( '' !== $src ) { $alt = $this->lookup_alt_for_url( $src ); } } if ( '' !== $alt ) { $processor->set_attribute( 'alt', $alt ); } } return $processor->get_updated_html(); } /** * Filter for wp_get_attachment_image_attributes - hit by featured * images, gallery shortcode, the core image block, theme/plugin code * that calls wp_get_attachment_image(). Fills the alt key when it is * absent or blank; a blank one is usually a theme passing alt => '' of its * own accord, not an author marking the image decorative. Testing for the * key alone would never fill anything: core always passes an alt key, so * every themed image would keep the theme's empty alt - and a linked * thumbnail with alt="" leaves its link with no accessible name at all. * Alt text already set is left alone, and nothing is injected unless the * Media Library holds alt text for the attachment. * * @param array $attr Attribute array. * @param WP_Post|null $attachment Attachment post object. */ public function inject_alt_into_attributes( array $attr, $attachment = null ): array { if ( isset( $attr['alt'] ) && '' !== trim( (string) $attr['alt'] ) ) { return $attr; } if ( ! ( $attachment instanceof WP_Post ) ) { return $attr; } $alt = (string) get_post_meta( (int) $attachment->ID, '_wp_attachment_image_alt', true ); if ( '' !== trim( $alt ) ) { $attr['alt'] = $alt; } return $attr; } /** * Resolves an image URL (original or any size variant) to the saved * alt text on the matching attachment. Returns '' when no attachment * matches or no alt is saved. Cached per request. * * @param string $url Image URL pulled from an . */ private function lookup_alt_for_url( string $url ): string { if ( isset( $this->alt_url_cache[ $url ] ) ) { return $this->alt_url_cache[ $url ]; } $attachment_id = attachment_url_to_postid( $url ); // Variant URL like image-300x200.jpg won't match the stored full // URL. Try again with the size suffix stripped before giving up. if ( 0 === $attachment_id ) { $stripped = (string) preg_replace( '/-\d+x\d+(?=\.[A-Za-z0-9]+(?:\?|$))/', '', $url ); if ( $stripped !== $url ) { $attachment_id = attachment_url_to_postid( $stripped ); } } $alt = ''; if ( $attachment_id > 0 ) { $alt = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); $alt = trim( $alt ); } $this->alt_url_cache[ $url ] = $alt; return $alt; } /** * Heuristic: does this attachment look like a decorative image (icon, * divider, spacer, background) rather than informative content? We use * three cheap signals from the attachment metadata only - no DOM * context needed, because we operate on the Media Library, not on * individual page renders. * * 1. Tiny pixel area (< 50x50 px) - icons, dividers, spacers. * 2. Extreme aspect ratio (>= 10:1 or <= 1:10) - separator lines, * banner strips that are almost always background decor. * 3. Filename contains a clearly-decorative word (icon, logo, divider, * spacer, bg, background, pixel, sprite, ornament, decoration). * * Any one signal is enough to flag the image as decorative. False * positives are real (a small but informative thumbnail, a "logo.jpg" * that is actually a key visual), but the user can still manually set * alt on those - we only refuse to AUTO-generate so we do not flood * the Media Library with "Image of a logo." style filler. * * @param int $attachment_id Attachment post ID. */ public static function is_likely_decorative( int $attachment_id ): bool { // A post thumbnail (featured image) is rendered as a LINKED preview // (related posts, archive cards), so even a "logo"/"icon"-named one // still needs alt - otherwise that link has no accessible name // (linked-image-empty-alt). Never treat a featured image as decorative. if ( self::is_featured_image( $attachment_id ) ) { return false; } $meta = wp_get_attachment_metadata( $attachment_id ); $w = is_array( $meta ) ? (int) ( $meta['width'] ?? 0 ) : 0; $h = is_array( $meta ) ? (int) ( $meta['height'] ?? 0 ) : 0; // 1. Tiny image - well below "informative content" threshold. if ( $w > 0 && $h > 0 && $w < 50 && $h < 50 ) { return true; } // 2. Extreme aspect ratio - long thin bars / vertical separators. if ( $w > 0 && $h > 0 ) { $ratio = $w / $h; if ( $ratio >= 10 || $ratio <= 0.1 ) { return true; } } // 3. Filename hint. $file = is_array( $meta ) ? (string) ( $meta['file'] ?? '' ) : ''; if ( '' === $file ) { $file = (string) get_post_meta( $attachment_id, '_wp_attached_file', true ); } $basename = strtolower( basename( $file ) ); $signals = array( 'icon', 'logo', 'divider', 'spacer', 'bg-', '-bg.', 'background', 'pixel', 'sprite', 'ornament', 'decoration' ); foreach ( $signals as $needle ) { if ( '' !== $needle && false !== strpos( $basename, $needle ) ) { return true; } } return false; } /** * Whether the attachment is used as a post thumbnail (featured image) * anywhere on the site. Featured images render as linked previews, so * they must NOT be skipped as decorative. The full set of thumbnail IDs * is fetched once per request and cached in a static for cheap lookups. * * @param int $attachment_id Attachment ID. * @return bool */ private static function is_featured_image( int $attachment_id ): bool { static $thumb_ids = null; if ( null === $thumb_ids ) { global $wpdb; $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery -- One-time per-request read of featured-image IDs, memoised in a static; no core API returns this set. "SELECT DISTINCT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id'" ); $thumb_ids = array_map( 'intval', (array) $ids ); } return in_array( $attachment_id, $thumb_ids, true ); } /** * Count of INFORMATIVE image attachments whose * `_wp_attachment_image_alt` is empty or missing. Used by the * dashboard "Alt text coverage" card so the customer sees their * actual alt-text gap shrink as AI alt fills it. Decorative images * (filtered by is_likely_decorative) are excluded so the number * reflects work actually worth doing, not the raw Media Library * total. * * Cached for 5 minutes via a transient because the meta_query is the * same one bulk-alt uses for the candidate list, and dashboards reload * far more often than the Media Library changes. Cache is invalidated * whenever this class saves alt to an attachment (auto-on-upload, single * REST call, or bulk - all funnel through update_post_meta on the * `_wp_attachment_image_alt` key). * * @return int Number of informative image attachments lacking alt text. */ public static function count_images_without_alt(): int { $cached = get_transient( self::ALT_COUNT_TRANSIENT ); if ( false !== $cached ) { return (int) $cached; } $count = 0; foreach ( Acceguru_Scanner::post_ids_in_batches( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ), 'orderby' => array( 'date' => 'DESC', 'ID' => 'DESC' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- intentional one-off scan, cached for 5 minutes. 'meta_query' => array( 'relation' => 'OR', array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS', ), array( 'key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '=', ), ), ) ) as $id ) { if ( ! self::is_likely_decorative( $id ) ) { ++$count; } } set_transient( self::ALT_COUNT_TRANSIENT, $count, 5 * MINUTE_IN_SECONDS ); return $count; } /** * Drops the cached "images without alt" count. Called whenever this * class writes alt to an attachment so the dashboard card reflects the * new state on the very next page load. */ public static function invalidate_alt_count_cache(): void { delete_transient( self::ALT_COUNT_TRANSIENT ); delete_transient( 'acceguru_images_without_alt_count' ); } /** * Meta-change hook: invalidate the coverage-count cache whenever the * relevant attachment meta key is added, updated, or deleted through * WordPress. The 3rd action arg is the meta key for added/updated * (meta_id, object_id, meta_key) and for deleted (meta_ids, object_id, * meta_key), so a single signature covers all three. * * @param int|array $meta_id Meta row id(s). Unused. * @param int $object_id Attachment ID. Unused. * @param string $meta_key Meta key that changed. */ public function maybe_invalidate_alt_cache_on_meta( $meta_id, $object_id, $meta_key ): void { unset( $meta_id, $object_id ); $relevant_meta_keys = array( '_wp_attachment_image_alt', '_wp_attachment_metadata', '_wp_attached_file', ); if ( in_array( $meta_key, $relevant_meta_keys, true ) ) { self::invalidate_alt_count_cache(); } } // ------------------------------------------------------------------------- // REST API // ------------------------------------------------------------------------- /** * Registers the alt-text REST routes (bulk candidates, bulk generate, key test). */ public function register_rest_routes(): void { register_rest_route( self::REST_NAMESPACE, '/bulk-alt-candidates', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'handle_bulk_candidates_request' ), 'permission_callback' => array( $this, 'check_admin_permissions' ), ) ); register_rest_route( self::REST_NAMESPACE, '/bulk-generate-alt-text', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_bulk_generate_request' ), 'permission_callback' => array( $this, 'check_bulk_generate_permissions' ), 'args' => array( 'attachment_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/test-ai-key', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_test_key_request' ), 'permission_callback' => array( $this, 'check_admin_permissions' ), 'args' => array( 'provider' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), 'model' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * Handles POST /test-ai-key. Sends a tiny text-only chat-completions / * generateContent request to the user's selected provider/model. This * exercises the same auth + billing + model-access path as real alt-text * generation (just without an image), so a "Key works" response actually * means a subsequent bulk run can succeed. A models-list ping would * falsely report success when the key is valid but billing/credits are * unset. * * Cost: ~10 tokens in + ~5 tokens out, fractions of a cent regardless * of provider. One HTTP round-trip. * * @return WP_REST_Response|WP_Error */ public function handle_test_key_request( WP_REST_Request $request ): WP_REST_Response|WP_Error { // provider/model are optional: the Test button passes the values // currently selected in the form; the bulk-generation preflight sends // an empty body and falls back to the saved provider/model. $provider = sanitize_key( (string) $request->get_param( 'provider' ) ); if ( ! in_array( $provider, self::PROVIDERS, true ) ) { $provider = $this->get_provider(); } $api_key = $this->get_decrypted_api_key( $provider ); if ( '' === $api_key ) { return new WP_Error( 'acceguru_missing_api_key', sprintf( /* translators: %s: provider name */ __( 'No %s API key configured.', 'acceguru' ), self::provider_label( $provider ) ), array( 'status' => 400 ) ); } // Validate the requested model against the provider's allow-list; // fall back to the saved model for anything unrecognised. $model = (string) $request->get_param( 'model' ); if ( ! in_array( $model, self::ALLOWED_MODELS[ $provider ] ?? array(), true ) ) { $model = $this->get_model_for_provider( $provider ); } if ( 'mistral' === $provider ) { return $this->execute_test_call( 'mistral', 'https://api.mistral.ai/v1/chat/completions', wp_json_encode( array( 'model' => $model, 'max_tokens' => 5, 'messages' => array( array( 'role' => 'user', 'content' => 'Reply with the single word OK.', ), ), ) ), array( 'Authorization' => 'Bearer ' . $api_key ) ); } if ( 'openai' === $provider ) { return $this->execute_test_call( 'openai', 'https://api.openai.com/v1/chat/completions', wp_json_encode( array( 'model' => $model, 'max_tokens' => 5, 'messages' => array( array( 'role' => 'user', 'content' => 'Reply with the single word OK.', ), ), ) ), array( 'Authorization' => 'Bearer ' . $api_key ) ); } if ( 'anthropic' === $provider ) { return $this->execute_test_call( 'anthropic', 'https://api.anthropic.com/v1/messages', wp_json_encode( array( 'model' => $model, 'max_tokens' => 5, 'messages' => array( array( 'role' => 'user', 'content' => 'Reply with the single word OK.', ), ), ) ), array( 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01', ) ); } // Gemini. return $this->execute_test_call( 'gemini', 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode( $model ) . ':generateContent?key=' . rawurlencode( $api_key ), wp_json_encode( array( 'contents' => array( array( 'parts' => array( array( 'text' => 'Reply with the single word OK.' ), ), ), ), 'generationConfig' => array( 'maxOutputTokens' => 10, ), ) ), array() ); } /** * Common test-call executor: posts the prebuilt body, returns a * REST_Response on 200 or a WP_Error carrying the provider's own error * message verbatim on anything else. Centralises the response/error * handling that used to be duplicated per-provider. * * @param string $provider Provider slug (for error context). * @param string $url Full request URL. * @param string|false $body JSON-encoded request body (output of wp_json_encode). * @param array $extra_headers Provider-specific headers (e.g. Authorization). */ private function execute_test_call( string $provider, string $url, string|false $body, array $extra_headers ): WP_REST_Response|WP_Error { $headers = array_merge( array( 'Content-Type' => 'application/json' ), $extra_headers ); $response = wp_remote_post( $url, array( 'timeout' => 30, 'headers' => $headers, 'body' => false === $body ? '' : $body, ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'acceguru_test_transport_error', sprintf( /* translators: %s: error message */ __( 'Could not reach provider: %s', 'acceguru' ), $response->get_error_message() ), array( 'status' => 502 ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { $message = $this->extract_provider_error_message( $provider, $data ); if ( '' === $message ) { $message = __( 'Request failed.', 'acceguru' ); } return new WP_Error( 'acceguru_test_key_invalid', sprintf( /* translators: 1: HTTP status, 2: provider error */ __( 'Test failed (HTTP %1$d): %2$s', 'acceguru' ), $code, $message ), array( 'status' => ( $code >= 400 && $code < 600 ) ? $code : 502 ) ); } return new WP_REST_Response( array( 'success' => true ), 200 ); } /** * Permission callback for admin-only alt-text routes (test key, bulk * candidates). Both use the site-wide configured API key and spend the * provider's quota, so they are limited to users who manage options. */ public function check_admin_permissions(): bool { return current_user_can( 'manage_options' ); } /** * Permission callback for the per-attachment bulk generator. Requires the * media capability AND edit rights on the specific attachment, checked here * - before the file is read or sent to the AI provider - so a user cannot * run another user's image through the paid service or overwrite its alt. * The attachment id is still raw at permission time, so cast it directly. * * @param WP_REST_Request $request Request instance. */ public function check_bulk_generate_permissions( WP_REST_Request $request ): bool { $attachment_id = absint( $request->get_param( 'attachment_id' ) ); return current_user_can( 'upload_files' ) && current_user_can( 'edit_post', $attachment_id ); } /** * Returns the IDs of image attachments that currently lack alt text. * Uses a meta_query for empty/missing _wp_attachment_image_alt so the * query stays indexed and avoids loading all attachments into memory. */ public function handle_bulk_candidates_request(): WP_REST_Response { // Filter out attachments the decorative heuristic believes do not // need alt text (tiny icons, dividers, files named "logo"/"bg"/ // "spacer", extreme aspect ratios). Saves Mistral tokens and keeps // the dashboard count honest about how much real work is left. $skipped = 0; $ids = array(); foreach ( Acceguru_Scanner::post_ids_in_batches( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ), 'orderby' => array( 'date' => 'DESC', 'ID' => 'DESC' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bulk Alt Text page runs this on demand to find unlabelled images; no equivalent core query. 'meta_query' => array( 'relation' => 'OR', array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS', ), array( 'key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '=', ), ), ) ) as $id ) { if ( self::is_likely_decorative( $id ) ) { ++$skipped; continue; } $ids[] = $id; } // This endpoint performs the same live count as the dashboard. Store // the fresh result so returning to the dashboard cannot show an older // value while the Bulk Alt Text screen shows the current one. set_transient( self::ALT_COUNT_TRANSIENT, count( $ids ), 5 * MINUTE_IN_SECONDS ); return new WP_REST_Response( array( 'ids' => $ids, 'count' => count( $ids ), 'skipped' => $skipped, ), 200 ); } /** * Generates and saves alt text for a single attachment by ID. * * @param WP_REST_Request $request Request instance. */ public function handle_bulk_generate_request( WP_REST_Request $request ): WP_REST_Response|WP_Error { $attachment_id = (int) $request->get_param( 'attachment_id' ); if ( ! wp_attachment_is_image( $attachment_id ) ) { return new WP_Error( 'acceguru_not_an_image', __( 'Attachment is not an image.', 'acceguru' ), array( 'status' => 400 ) ); } $title = (string) get_the_title( $attachment_id ); $image_url = (string) wp_get_attachment_url( $attachment_id ); if ( '' === $image_url ) { return new WP_Error( 'acceguru_missing_url', __( 'Attachment has no URL.', 'acceguru' ), array( 'status' => 400 ) ); } $alt = $this->generate_alt_text( $image_url ); if ( is_wp_error( $alt ) ) { return $alt; } if ( '' === $alt ) { return new WP_Error( 'acceguru_empty_result', __( 'Provider returned empty alt text.', 'acceguru' ), array( 'status' => 502 ) ); } update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); self::invalidate_alt_count_cache(); return new WP_REST_Response( array( 'attachment_id' => $attachment_id, 'title' => $title, 'alt_text' => $alt, ), 200 ); } // ------------------------------------------------------------------------- // Provider dispatch // ------------------------------------------------------------------------- /** * Generates alt text for a single image URL using the configured provider. * Reads provider, key and model freshly on each call so a swap on the * Settings page takes effect on the very next request without reload. * * @param string $image_url Public URL of the image. */ public function generate_alt_text( string $image_url ): string|WP_Error { $provider = $this->get_provider(); $api_key = $this->get_decrypted_api_key( $provider ); if ( '' === $api_key ) { return new WP_Error( 'acceguru_missing_api_key', sprintf( /* translators: %s: provider name (e.g. "Google Gemini") */ __( 'No %s API key configured. Add one under AcceGuru → Settings → AI Alt Text.', 'acceguru' ), self::provider_label( $provider ) ), array( 'status' => 400 ) ); } $model = $this->get_model_for_provider( $provider ); $max_length = (int) get_option( 'acceguru_ai_max_alt_length', 125 ); $prompt = $this->build_prompt( $max_length ); switch ( $provider ) { case 'mistral': return $this->generate_mistral( $image_url, $api_key, $model, $prompt ); case 'openai': return $this->generate_openai( $image_url, $api_key, $model, $prompt ); case 'anthropic': return $this->generate_anthropic( $image_url, $api_key, $model, $prompt ); default: return $this->generate_gemini( $image_url, $api_key, $model, $prompt ); } } /** * Builds the shared prompt for both providers. * * @param int $max_length Max characters. */ private function build_prompt( int $max_length ): string { return sprintf( /* translators: %d: max character length for alt text */ __( 'Write concise, descriptive alt text for this image for screen-reader users. Describe the essential visual content in a single sentence. Do not start with "Image of" or "Picture of". Do not add quotes. Maximum %d characters.', 'acceguru' ), $max_length ); } // ------------------------------------------------------------------------- // Gemini provider // ------------------------------------------------------------------------- /** * Generates alt text via Google Gemini generateContent (vision). * Sends the image as inline base64 data. Authentication is via the * `key` query parameter (Gemini REST convention). * * @param string $image_url Image URL. * @param string $api_key Gemini API key. * @param string $model Model ID (e.g. gemini-3.1-flash-lite). * @param string $prompt Text prompt. */ private function generate_gemini( string $image_url, string $api_key, string $model, string $prompt ): string|WP_Error { $image = $this->fetch_image_as_base64( $image_url ); if ( is_wp_error( $image ) ) { return $image; } $body = array( 'contents' => array( array( 'parts' => array( array( 'text' => $prompt ), array( 'inline_data' => array( 'mime_type' => $image['media_type'], 'data' => $image['data'], ), ), ), ), ), ); $endpoint = sprintf( 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s', rawurlencode( $model ), rawurlencode( $api_key ) ); $response = wp_remote_post( $endpoint, array( 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return $this->wrap_transport_error( $response ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { return $this->remote_http_error( 'gemini', $code, $data ); } $text = isset( $data['candidates'][0]['content']['parts'][0]['text'] ) ? (string) $data['candidates'][0]['content']['parts'][0]['text'] : ''; return $this->finalize_alt_text( $text ); } // ------------------------------------------------------------------------- // Mistral provider // ------------------------------------------------------------------------- /** * Generates alt text via Mistral's chat/completions endpoint (OpenAI-shaped * payload). The image is sent as an inline base64 data URL under the * `image_url` content part, which the current Mistral vision models * accept. Authentication is a Bearer token in the Authorization header. * * @param string $image_url Image URL. * @param string $api_key Mistral API key. * @param string $model Model ID (e.g. mistral-small-latest). * @param string $prompt Text prompt. */ private function generate_mistral( string $image_url, string $api_key, string $model, string $prompt ): string|WP_Error { $image = $this->fetch_image_as_base64( $image_url ); if ( is_wp_error( $image ) ) { return $image; } $data_url = sprintf( 'data:%s;base64,%s', $image['media_type'], $image['data'] ); $body = array( 'model' => $model, 'max_tokens' => 200, 'messages' => array( array( 'role' => 'user', 'content' => array( array( 'type' => 'text', 'text' => $prompt, ), array( 'type' => 'image_url', 'image_url' => array( 'url' => $data_url ), ), ), ), ), ); $response = wp_remote_post( 'https://api.mistral.ai/v1/chat/completions', array( 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return $this->wrap_transport_error( $response ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { return $this->remote_http_error( 'mistral', $code, $data ); } $content = $data['choices'][0]['message']['content'] ?? ''; // Mistral usually returns content as a plain string. The spec also // allows an array of content parts; if we get that, concat their // text. Defensive against future shape changes. if ( is_array( $content ) ) { $buf = ''; foreach ( $content as $part ) { if ( is_array( $part ) && isset( $part['text'] ) ) { $buf .= (string) $part['text']; } } $content = $buf; } return $this->finalize_alt_text( (string) $content ); } // ------------------------------------------------------------------------- // OpenAI provider // ------------------------------------------------------------------------- /** * Generates alt text via OpenAI's chat/completions endpoint. The payload * is the same OpenAI-compatible shape Mistral uses (text part + image_url * part with an inline base64 data URL), so the body is near-identical - * only the endpoint, auth header style, and default models differ. Auth * is a Bearer token. * * @param string $image_url Image URL. * @param string $api_key OpenAI API key. * @param string $model Model ID (e.g. gpt-4o-mini). * @param string $prompt Text prompt. */ private function generate_openai( string $image_url, string $api_key, string $model, string $prompt ): string|WP_Error { $image = $this->fetch_image_as_base64( $image_url ); if ( is_wp_error( $image ) ) { return $image; } $data_url = sprintf( 'data:%s;base64,%s', $image['media_type'], $image['data'] ); $body = array( 'model' => $model, 'max_tokens' => 200, 'messages' => array( array( 'role' => 'user', 'content' => array( array( 'type' => 'text', 'text' => $prompt, ), array( 'type' => 'image_url', 'image_url' => array( 'url' => $data_url ), ), ), ), ), ); $response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', array( 'timeout' => 30, 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return $this->wrap_transport_error( $response ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { return $this->remote_http_error( 'openai', $code, $data ); } $text = isset( $data['choices'][0]['message']['content'] ) ? (string) $data['choices'][0]['message']['content'] : ''; return $this->finalize_alt_text( $text ); } // ------------------------------------------------------------------------- // Anthropic (Claude) provider // ------------------------------------------------------------------------- /** * Generates alt text via Anthropic's Messages API. Unlike OpenAI/Mistral, * Claude uses its own request shape: the image is an `image` content block * with a base64 `source`, auth is the `x-api-key` header, and the API * version is pinned with `anthropic-version`. The reply text lives at * content[0].text. * * @param string $image_url Image URL. * @param string $api_key Anthropic API key. * @param string $model Model ID (e.g. claude-haiku-4-5-20251001). * @param string $prompt Text prompt. */ private function generate_anthropic( string $image_url, string $api_key, string $model, string $prompt ): string|WP_Error { $image = $this->fetch_image_as_base64( $image_url ); if ( is_wp_error( $image ) ) { return $image; } $body = array( 'model' => $model, 'max_tokens' => 200, 'messages' => array( array( 'role' => 'user', 'content' => array( array( 'type' => 'image', 'source' => array( 'type' => 'base64', 'media_type' => $image['media_type'], 'data' => $image['data'], ), ), array( 'type' => 'text', 'text' => $prompt, ), ), ), ), ); $response = wp_remote_post( 'https://api.anthropic.com/v1/messages', array( 'timeout' => 30, 'headers' => array( 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); if ( is_wp_error( $response ) ) { return $this->wrap_transport_error( $response ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code ) { return $this->remote_http_error( 'anthropic', $code, $data ); } // Claude returns content as an array of blocks; the first text block // holds the answer. $text = ''; if ( isset( $data['content'] ) && is_array( $data['content'] ) ) { foreach ( $data['content'] as $block ) { if ( is_array( $block ) && 'text' === ( $block['type'] ?? '' ) && isset( $block['text'] ) ) { $text .= (string) $block['text']; } } } return $this->finalize_alt_text( $text ); } /** * Fetches an image and returns its base64 payload + media type. * * Tries the local filesystem first when the URL belongs to this site's * uploads directory - this skips an HTTP round trip back to the server, * which on hosts with few PHP workers (Studio local dev, shared hosting * with one FPM worker per pool) often deadlocks: the bulk handler is * holding the only worker and the inner HTTP request to the same host * waits forever for a worker that never frees. Reading the file from * disk is also an order of magnitude faster and works on private sites * whose uploads are not publicly reachable. * * Falls back to wp_safe_remote_get for any URL that does not resolve * to a readable file on this site (external URLs, CDN-served images, * stale URLs whose file was deleted). * * @param string $image_url Image URL. * * @return array{data:string,media_type:string}|WP_Error */ private function fetch_image_as_base64( string $image_url ): array|WP_Error { $allowed = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ); // 1. Try the local filesystem if the URL is on this site's uploads. $local_path = $this->local_path_for_url( $image_url ); if ( null !== $local_path && is_readable( $local_path ) ) { $size = filesize( $local_path ); if ( false !== $size && $size > self::MAX_IMAGE_BYTES ) { return $this->image_too_large_error(); } // A zero-byte or unknown-size local file (e.g. an offloaded-media // stub) is left to the HTTP fallback rather than read as an image; // only a real, in-range file is pulled off disk. if ( false !== $size && $size > 0 ) { $body = file_get_contents( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- direct disk read avoids a self-HTTP request back to this same site, which deadlocks on hosts with a single PHP worker; guarded by is_readable() and the size check above. if ( false !== $body && '' !== $body ) { $ft = wp_check_filetype( $local_path ); $media_type = isset( $ft['type'] ) ? strtolower( (string) $ft['type'] ) : ''; if ( in_array( $media_type, $allowed, true ) ) { return array( 'data' => base64_encode( $body ), 'media_type' => $media_type, ); } // Unknown type from filename - fall through to HTTP, which // at least gets a real Content-Type header from the server. } } } // 2. HTTP fallback for external / non-local URLs. Cap the download so a // huge or malicious response cannot exhaust memory. $response = wp_safe_remote_get( $image_url, array( 'timeout' => 20, 'sslverify' => true, 'limit_response_size' => self::MAX_IMAGE_BYTES + 1, ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'acceguru_image_fetch_failed', /* translators: %s: underlying error message */ sprintf( __( 'Could not fetch image: %s', 'acceguru' ), $response->get_error_message() ), array( 'status' => 502 ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return new WP_Error( 'acceguru_image_fetch_failed', sprintf( /* translators: %d: HTTP status */ __( 'Could not fetch image (HTTP %d).', 'acceguru' ), $code ), array( 'status' => 502 ) ); } // Reject oversized responses: the advertised length first (cheap), then // the actual body after the capped download. $declared = (int) wp_remote_retrieve_header( $response, 'content-length' ); if ( $declared > self::MAX_IMAGE_BYTES ) { return $this->image_too_large_error(); } $body = (string) wp_remote_retrieve_body( $response ); if ( '' === $body ) { return new WP_Error( 'acceguru_image_empty', __( 'The image could not be read (empty response).', 'acceguru' ), array( 'status' => 502 ) ); } if ( strlen( $body ) > self::MAX_IMAGE_BYTES ) { return $this->image_too_large_error(); } $media_type = (string) wp_remote_retrieve_header( $response, 'content-type' ); $media_type = strtolower( trim( (string) strtok( $media_type, ';' ) ) ); if ( ! in_array( $media_type, $allowed, true ) ) { return new WP_Error( 'acceguru_image_unsupported_type', /* translators: %s: media type */ sprintf( __( 'Unsupported image type (%s). Use JPEG, PNG, GIF, or WebP.', 'acceguru' ), $media_type ), array( 'status' => 415 ) ); } return array( 'data' => base64_encode( $body ), 'media_type' => $media_type, ); } /** * Builds the "image too large" WP_Error (HTTP 413) shared by the local and * HTTP size guards. */ private function image_too_large_error(): WP_Error { return new WP_Error( 'acceguru_image_too_large', sprintf( /* translators: %s: maximum image size in megabytes */ __( 'Image is too large. Maximum allowed size is %s MB.', 'acceguru' ), number_format_i18n( self::MAX_IMAGE_BYTES / MB_IN_BYTES ) ), array( 'status' => 413 ) ); } /** * Maps an image URL to a readable filesystem path when the URL points * inside this site's uploads directory. Returns null otherwise (external * URL, missing file, or a path that resolves outside uploads). * * Handles resized variants (e.g. image-300x200.jpg) - subsize files live * alongside the original in the same folder. The resolved real path is * confirmed to sit inside the uploads directory with a directory boundary, * so a crafted "../", a prefix-alike path such as /uploads-other, or a * symlink pointing outside uploads is rejected. * * @param string $url Candidate image URL. */ private function local_path_for_url( string $url ): ?string { $upload = wp_get_upload_dir(); if ( empty( $upload['baseurl'] ) || empty( $upload['basedir'] ) ) { return null; } // Compare schemes loosely so an https URL still matches an // http-saved baseurl (and vice versa). $strip_scheme = static function ( string $u ): string { return preg_replace( '#^https?://#i', '//', $u ); }; $norm_url = $strip_scheme( $url ); $norm_baseurl = $strip_scheme( (string) $upload['baseurl'] ); if ( 0 !== strpos( $norm_url, $norm_baseurl ) ) { return null; } $relative = substr( $norm_url, strlen( $norm_baseurl ) ); // Drop any query string before mapping to disk. $qpos = strpos( $relative, '?' ); if ( false !== $qpos ) { $relative = substr( $relative, 0, $qpos ); } // Decode %20 and friends so filenames with encoded characters resolve, // then reject a null byte before it can truncate the path. $relative = rawurldecode( $relative ); if ( false !== strpos( $relative, "\0" ) ) { return null; } $candidate = rtrim( (string) $upload['basedir'], '/\\' ) . $relative; // Resolve symlinks and "../", then confirm the real path is a file that // actually lives inside uploads (directory boundary via the trailing // slash). realpath() returns false for a missing path, so a file not on // local disk falls through to the HTTP fetch instead of erroring. $base_real = realpath( $upload['basedir'] ); $file_real = realpath( $candidate ); if ( false === $base_real || false === $file_real || ! is_file( $file_real ) ) { return null; } $base = trailingslashit( wp_normalize_path( $base_real ) ); $file = wp_normalize_path( $file_real ); if ( 0 !== strpos( $file, $base ) ) { return null; } return $file; } // ------------------------------------------------------------------------- // Error mapping // ------------------------------------------------------------------------- /** * Maps a wp_remote transport error to a WP_Error with status 502. * * @param WP_Error $error Original error. */ private function wrap_transport_error( WP_Error $error ): WP_Error { return new WP_Error( 'acceguru_http_transport', /* translators: %s: underlying error message */ sprintf( __( 'Network error contacting AI provider: %s', 'acceguru' ), $error->get_error_message() ), array( 'status' => 502 ) ); } /** * Maps a non-2xx HTTP response to a structured WP_Error. * * @param string $provider Provider slug. * @param int $code HTTP status from the provider. * @param array|null $data Decoded response body. */ private function remote_http_error( string $provider, int $code, ?array $data ): WP_Error { $message = $this->extract_provider_error_message( $provider, $data ); if ( 401 === $code || 403 === $code ) { return new WP_Error( 'acceguru_ai_auth_failed', '' !== $message ? $message : __( 'Authentication failed. Check your API key.', 'acceguru' ), array( 'status' => 401 ) ); } if ( 429 === $code ) { return new WP_Error( 'acceguru_ai_rate_limited', '' !== $message ? $message : __( 'Rate limit reached. Try again in a moment.', 'acceguru' ), array( 'status' => 429 ) ); } if ( $code >= 500 ) { return new WP_Error( 'acceguru_ai_upstream_error', '' !== $message ? $message : __( 'AI provider is temporarily unavailable.', 'acceguru' ), array( 'status' => 502 ) ); } return new WP_Error( 'acceguru_ai_request_failed', /* translators: 1: provider slug, 2: HTTP status, 3: provider error message */ sprintf( __( '%1$s request failed (HTTP %2$d): %3$s', 'acceguru' ), $provider, $code, $message ), array( 'status' => 502 ) ); } /** * Extracts a readable message from a provider error payload. * * @param string $provider Provider slug. * @param array|null $data Decoded response body. */ private function extract_provider_error_message( string $provider, ?array $data ): string { unset( $provider ); if ( null === $data ) { return ''; } // Gemini: { "error": { "message": "..." } } if ( isset( $data['error']['message'] ) ) { return (string) $data['error']['message']; } // Mistral (top-level message) and any provider that returns the // OpenAI/FastAPI shape: { "message": "..." } if ( isset( $data['message'] ) ) { if ( is_string( $data['message'] ) ) { return $data['message']; } if ( is_array( $data['message'] ) && isset( $data['message'][0]['msg'] ) ) { return (string) $data['message'][0]['msg']; } } // FastAPI validation errors: { "detail": [{...,"msg":"..."}] } or // { "detail": "..." } if ( isset( $data['detail'] ) ) { if ( is_string( $data['detail'] ) ) { return $data['detail']; } if ( is_array( $data['detail'] ) && isset( $data['detail'][0]['msg'] ) ) { return (string) $data['detail'][0]['msg']; } } return ''; } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Trims and hard-caps output at the configured max length, erring short. * * @param string $text Raw model output. */ private function finalize_alt_text( string $text ): string { $text = trim( $text ); $text = trim( $text, "\"'" ); $max = (int) get_option( 'acceguru_ai_max_alt_length', 125 ); if ( $max > 0 && function_exists( 'mb_strlen' ) && mb_strlen( $text ) > $max ) { $text = rtrim( mb_substr( $text, 0, $max - 1 ) ) . '…'; } return sanitize_text_field( $text ); } /** * Returns the currently selected AI provider slug. Defaults to gemini * and clamps unknown values back to gemini so a stale or hand-edited * option can never escape the dispatch table. */ public function get_provider(): string { $provider = (string) get_option( 'acceguru_ai_provider', 'gemini' ); return in_array( $provider, self::PROVIDERS, true ) ? $provider : 'gemini'; } /** * Returns the wp_options key that stores the encrypted API key for the * given provider. Gemini keeps the legacy `acceguru_ai_api_key` name * so installs that saved a Gemini key before the multi-provider split * do not lose it. Other providers get a namespaced option name. * * @param string $provider Provider slug. */ public static function api_key_option_name( string $provider ): string { return 'gemini' === $provider ? 'acceguru_ai_api_key' : 'acceguru_ai_api_key_' . $provider; } /** * Returns the wp_options key that stores the selected model for the * given provider. Same legacy-name rule as api_key_option_name: Gemini * keeps `acceguru_ai_model`, others are namespaced. * * @param string $provider Provider slug. */ public static function model_option_name( string $provider ): string { return 'gemini' === $provider ? 'acceguru_ai_model' : 'acceguru_ai_model_' . $provider; } /** * Returns the saved model for the given provider, falling back to the * per-provider default in self::DEFAULT_MODELS if nothing is saved. * * @param string $provider Provider slug. Empty = current provider. */ public function get_model_for_provider( string $provider = '' ): string { if ( '' === $provider ) { $provider = $this->get_provider(); } $default = self::DEFAULT_MODELS[ $provider ] ?? self::DEFAULT_MODELS['gemini']; $model = (string) get_option( self::model_option_name( $provider ), '' ); // Reject a saved model that is no longer supported (e.g. a retired ID // left over from before a plugin update) so upgraded sites fall back to // a current model instead of calling a removed one. $allowed = self::ALLOWED_MODELS[ $provider ] ?? array(); return in_array( $model, $allowed, true ) ? $model : $default; } /** * Decrypts and returns the stored API key for a provider. * * @param string $provider Provider slug. Empty = currently selected provider. */ public function get_decrypted_api_key( string $provider = '' ): string { if ( '' === $provider ) { $provider = $this->get_provider(); } return self::decrypt_api_key( (string) get_option( self::api_key_option_name( $provider ), '' ) ); } /** * Prefix marking values encrypted with AES-256-GCM (random 12-byte IV * per encryption, 16-byte auth tag). Values without this prefix are not * recognised as keys and are ignored on read. */ private const CIPHER_V2_PREFIX = 'a11y2:'; /** GCM parameters. */ private const GCM_CIPHER = 'aes-256-gcm'; private const GCM_IV_LEN = 12; private const GCM_TAG_LEN = 16; /** * Whether AES-256-GCM is available in this PHP build. */ private static function gcm_available(): bool { return function_exists( 'openssl_encrypt' ) && in_array( self::GCM_CIPHER, openssl_get_cipher_methods(), true ); } /** * Encrypts an API key with AES-256-GCM. * * A fresh random IV is used for every call; output is * CIPHER_V2_PREFIX . base64( iv . tag . ciphertext ). The key is never * stored unencrypted: if the GCM cipher is unavailable or encryption * fails, this throws so the caller can refuse to save the key. * * @param string $plain Plain-text API key. * @return string Encrypted, prefixed value ('' for empty input). * @throws \RuntimeException When AES-256-GCM is unavailable or encryption fails. */ public static function encrypt_api_key( string $plain ): string { if ( '' === $plain ) { return ''; } if ( ! self::gcm_available() ) { throw new \RuntimeException( 'AES-256-GCM is not available on this server.' ); } try { $iv = random_bytes( self::GCM_IV_LEN ); } catch ( \Exception $e ) { throw new \RuntimeException( 'Could not generate a secure IV for encryption.' ); } $tag = ''; $cipher = openssl_encrypt( $plain, self::GCM_CIPHER, self::derive_gcm_key(), OPENSSL_RAW_DATA, $iv, $tag, '', self::GCM_TAG_LEN ); if ( false === $cipher || self::GCM_TAG_LEN !== strlen( $tag ) ) { throw new \RuntimeException( 'API key encryption failed.' ); } return self::CIPHER_V2_PREFIX . base64_encode( $iv . $tag . $cipher ); } /** * Decrypts a stored API key. * * Stored keys are AES-256-GCM payloads written by encrypt_api_key(). * Anything else - empty, or an unrecognised/legacy value - returns '' * so the caller treats it as "no key configured". * * @param string $encrypted Encrypted value from wp_options. */ public static function decrypt_api_key( string $encrypted ): string { if ( '' === $encrypted || ! str_starts_with( $encrypted, self::CIPHER_V2_PREFIX ) ) { return ''; } if ( ! self::gcm_available() ) { return ''; } $raw = base64_decode( substr( $encrypted, strlen( self::CIPHER_V2_PREFIX ) ), true ); if ( false === $raw || strlen( $raw ) <= self::GCM_IV_LEN + self::GCM_TAG_LEN ) { return ''; } $iv = substr( $raw, 0, self::GCM_IV_LEN ); $tag = substr( $raw, self::GCM_IV_LEN, self::GCM_TAG_LEN ); $cipher = substr( $raw, self::GCM_IV_LEN + self::GCM_TAG_LEN ); $plain = openssl_decrypt( $cipher, self::GCM_CIPHER, self::derive_gcm_key(), OPENSSL_RAW_DATA, $iv, $tag ); return false === $plain ? '' : $plain; } /** * Derives the 32-byte AES-256-GCM key from the WordPress auth salt. * * @return string 32 raw bytes. */ private static function derive_gcm_key(): string { return hash( 'sha256', wp_salt( 'auth' ) . '|acceguru-gcm', true ); } }