markup the editors store. * 3. A client-side scanner, for page builders that write their own markup. * Layers 1 and 2 also register the vault IDs used on the page so the scanner * can match them exactly instead of guessing from a filename. */ class Affirm_Frontend_Renderer { public function __construct() { // Always load SDK on frontend add_action('wp_enqueue_scripts', array($this, 'enqueue_sdk')); // Register/enqueue CSS for vault images inside WooCommerce galleries add_action('wp_enqueue_scripts', array($this, 'enqueue_woo_vault_styles')); // Featured image → SDK div add_filter('post_thumbnail_html', array($this, 'render_protected_thumbnail'), 10, 5); // og:image → signed URL (for social sharing / SEO) add_action('wp_head', array($this, 'output_og_image'), 5); // Content filter — replace data-affirm-id imgs with SDK divs add_filter('the_content', array($this, 'filter_content_images'), 20); // WooCommerce support add_filter('woocommerce_single_product_image_thumbnail_id', array($this, 'woo_product_image'), 10, 1); add_action('woocommerce_product_thumbnails', array($this, 'render_woo_gallery_images'), 20); // Replace WooCommerce main product image with vault image add_filter('woocommerce_single_product_image_thumbnail_html', array($this, 'woo_single_product_image_html'), 10, 2); // Replace product listing/archive thumbnails add_filter('woocommerce_product_get_image', array($this, 'woo_product_listing_image'), 10, 5); // Media-library proxies → protected canvas, everywhere add_filter('wp_get_attachment_image', array($this, 'protect_attachment_image'), 10, 5); add_filter('wp_get_attachment_image_src', array($this, 'note_attachment_src'), 10, 2); add_filter('wp_get_attachment_url', array($this, 'note_attachment_url'), 10, 2); // Ahead of filter_content_images() so a proxy becomes a canvas // before the data-affirm-id pass runs. add_filter('the_content', array($this, 'protect_content_attachments'), 19); add_action('wp_footer', array($this, 'print_protected_ids'), 5); } /** * Whether protected rendering applies to the current request. * * Admin screens, AJAX and REST must keep returning the real preview: that is * what the media modal, the block editor and a builder's own panel show the * author while they pick an image. Feeds need a fetchable URL, which the * og:image path supplies separately. */ private function should_protect() { if (!get_option('affirm_api_key')) return false; if (is_admin()) return false; if (wp_doing_ajax()) return false; if (defined('REST_REQUEST') && REST_REQUEST) return false; // Conditional tags are only meaningful once the main query has run, and // an image can be rendered before that. A feed needs a fetchable URL. if (did_action('wp') && is_feed()) return false; return true; } /** * Core path: anything rendered with wp_get_attachment_image() — featured * images, most themes, ACF, WooCommerce, countless plugins. */ public function protect_attachment_image($html, $attachment_id, $size, $icon, $attr) { if (!$this->should_protect()) return $html; $vault_id = Affirm_Media_Library::image_id_for($attachment_id); if (!$vault_id) return $html; return $this->render_attachment_embed($vault_id, $attachment_id, $size, is_array($attr) ? $attr : array()); } /** * Register the vault IDs a page builder is about to render. * * Elementor, Divi, WPBakery and friends ask WordPress for the URL and then * build their own markup, so there is no HTML for us to filter. Noting the ID * here lets the client-side scanner match that markup exactly. */ public function note_attachment_src($image, $attachment_id) { if ($this->should_protect()) { Affirm_Media_Library::mark_rendered(Affirm_Media_Library::image_id_for($attachment_id)); } return $image; } /** Same, for the plain-URL path used by CSS backgrounds and sliders. */ public function note_attachment_url($url, $attachment_id) { if ($this->should_protect()) { Affirm_Media_Library::mark_rendered(Affirm_Media_Library::image_id_for($attachment_id)); } return $url; } /** * Editor content path: the block and classic editors store raw markup * carrying a `wp-image-` class, which never passes through * wp_get_attachment_image(). */ public function protect_content_attachments($content) { if (!$content || !$this->should_protect()) return $content; if (strpos($content, 'wp-image-') === false) return $content; return preg_replace_callback('/]*>/i', function ($matches) { $tag = $matches[0]; if (!preg_match('/wp-image-(\d+)/', $tag, $found)) return $tag; $attachment_id = (int) $found[1]; $vault_id = Affirm_Media_Library::image_id_for($attachment_id); if (!$vault_id) return $tag; return $this->render_attachment_embed($vault_id, $attachment_id, 'large', array()); }, $content); } /** * Hand the scanner the exact vault IDs used on this page, as inline data in * front of the scanner file (which runs in the footer, after everything has * rendered). */ public function print_protected_ids() { if (!$this->should_protect()) return; $ids = Affirm_Media_Library::rendered_ids(); if (empty($ids)) return; wp_add_inline_script( 'affirm-native-images', 'window.affirmFirstProtectedIds = ' . wp_json_encode(array_values($ids)) . ';', 'before' ); } /** * Protected embed occupying the same box as the image it replaces. * * The proxy's stored dimensions give the aspect ratio, so the slot keeps its * shape and the page never shifts while the canvas streams in. */ private function render_attachment_embed($vault_id, $attachment_id, $size, $attr) { Affirm_Media_Library::mark_rendered($vault_id); $style = 'width:100%;max-width:100%;'; $meta = wp_get_attachment_image_src($attachment_id, $size); if ($meta && !empty($meta[1]) && !empty($meta[2])) { $style .= 'aspect-ratio:' . (int) $meta[1] . '/' . (int) $meta[2] . ';'; } if (get_option('affirm_render_mode', 'sdk') === 'iframe') { return sprintf( '', esc_url(affirm_get_backend_url() . '/sdk/embed/' . $vault_id), esc_attr($style) ); } $classes = 'affirmfirst-protected-image affirmfirst-media affirmfirst-media--fit'; if (!empty($attr['class'])) { $classes .= ' ' . $attr['class']; } return sprintf( '
', esc_attr($vault_id), esc_attr($classes), esc_attr($style) ); } /** * Always enqueue SDK script on all frontend pages. * The SDK auto-discovers data-affirmfirst divs and renders them as encrypted canvas. * Loaded in footer to avoid render-blocking. */ public function enqueue_sdk() { if (!get_option('affirm_api_key')) return; // Plugin not configured wp_enqueue_script( 'affirmfirst-sdk', affirm_get_sdk_url(), array(), AFFIRM_VERSION, true // Load in footer ); // Sizes the canvas to the box it replaced AND hides a proxy preview // before it can paint. Must be in the head to win that race, so it is // not made conditional on the page using a protected image. wp_enqueue_style( 'affirm-native-images', AFFIRM_PLUGIN_URL . 'assets/css/native-images.css', array(), AFFIRM_VERSION ); // Universal swap for markup WordPress never handed us — page builders, // sliders, CSS backgrounds. wp_enqueue_script( 'affirm-native-images', AFFIRM_PLUGIN_URL . 'assets/js/native-images.js', array(), AFFIRM_VERSION, true ); } /** * Render protected featured image (post thumbnail) as SDK div. * Featured images ALWAYS use SDK — never signed URL. * Signed URLs are only for og:image / SEO meta tags. */ public function render_protected_thumbnail($html, $post_id, $post_thumbnail_id, $size, $attr) { $affirm_id = get_post_meta($post_id, 'affirm_featured_image_id', true); if (!$affirm_id) return $html; $render_mode = get_option('affirm_render_mode', 'sdk'); // Featured images: SDK or iframe only — never signed_url for visible rendering if ($render_mode === 'iframe') { return $this->render_iframe_embed($affirm_id, $size, $attr); } return $this->render_sdk_embed($affirm_id, $size, $attr); } /** * Filter post content to replace aFFirmFirst images with SDK divs */ public function filter_content_images($content) { if (!$content) return $content; // Replace with SDK div $pattern = '/]+data-affirm-id=["\']([^"\']+)["\'][^>]*>/i'; $content = preg_replace_callback($pattern, function($matches) { $affirm_id = $matches[1]; $render_mode = get_option('affirm_render_mode', 'sdk'); if ($render_mode === 'iframe') { return $this->render_iframe_embed($affirm_id, 'large', array()); } return $this->render_sdk_embed($affirm_id, 'large', array()); }, $content); return $content; } /** * Render SDK canvas embed (maximum protection) * The SDK script auto-discovers these divs and renders encrypted canvas. */ private function render_sdk_embed($affirm_id, $size, $attr) { $dimensions = $this->get_dimensions($size, $attr); return sprintf( '
', esc_attr($affirm_id), esc_attr($dimensions['width']), esc_attr($dimensions['height']), esc_attr($dimensions['width']), esc_attr($dimensions['height']) ); } /** * Render iframe embed (high protection) * The iframe loads the backend's /sdk/embed/:imageId page which includes the SDK. */ private function render_iframe_embed($affirm_id, $size, $attr) { $dimensions = $this->get_dimensions($size, $attr); $embed_url = affirm_get_backend_url() . '/sdk/embed/' . esc_attr($affirm_id); return sprintf( '', esc_url($embed_url), esc_attr($dimensions['width']), esc_attr($dimensions['height']) ); } /** * Output og:image meta tag using signed URL. * Social crawlers (Facebook, Twitter, LinkedIn, Google) need a real image URL. * This is the ONLY place signed URLs are used for rendering — everything else uses SDK. */ public function output_og_image() { if (!is_singular()) return; global $post; if (!$post) return; $affirm_id = get_post_meta($post->ID, 'affirm_featured_image_id', true); if (!$affirm_id) return; // 7-day TTL — long enough for social crawlers to cache $signed_url = Affirm_Api::get_signed_url($affirm_id, 604800); if (!$signed_url) return; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; echo '' . "\n"; } /** * WooCommerce product image support * Return the thumbnail_id as-is. The actual rendering override happens * in woo_single_product_image_html filter. */ public function woo_product_image($thumbnail_id) { return $thumbnail_id; } /** * Replace the main product image HTML on single product pages. * WooCommerce calls wc_get_gallery_image_html() which generates an tag * from a WP attachment ID. Since vault images have no attachment, we replace * the entire HTML output with our SDK/iframe embed. * * @param string $html The default gallery image HTML * @param int $attachment_id The WP attachment ID (first image = main product image) */ public function woo_single_product_image_html($html, $attachment_id) { global $product; if (!$product) return $html; $post_id = $product->get_id(); // Check if this is the main product image (first call = main image) // WooCommerce calls this filter for the main image with the product's thumbnail_id $product_thumbnail_id = $product->get_image_id(); // Only replace the main image slot if it matches the product thumbnail // AND we have a vault featured image set if ($attachment_id == $product_thumbnail_id || !$product_thumbnail_id) { $affirm_id = get_post_meta($post_id, 'affirm_featured_image_id', true); if ($affirm_id) { return $this->render_woo_image_slide($affirm_id); } } return $html; } /** * Replace product listing/archive thumbnails with vault image. * This handles shop pages, related products, upsells, etc. */ public function woo_product_listing_image($image, $product, $size, $attr, $placeholder) { if (!$product) return $image; $post_id = $product->get_id(); $affirm_id = get_post_meta($post_id, 'affirm_featured_image_id', true); if (!$affirm_id) return $image; $render_mode = get_option('affirm_render_mode', 'sdk'); if ($render_mode === 'iframe') { $embed_url = affirm_get_backend_url() . '/sdk/embed/' . esc_attr($affirm_id); return sprintf( '', esc_url($embed_url) ); } return sprintf( '
', esc_attr($affirm_id) ); } /** * Render vault gallery images in WooCommerce product gallery * Hooks into woocommerce_product_thumbnails to add vault images * alongside any standard WP gallery images. */ public function render_woo_gallery_images() { global $product; if (!$product) return; $post_id = $product->get_id(); $vault_gallery = get_post_meta($post_id, '_affirm_product_gallery', true); if (!$vault_gallery || !is_array($vault_gallery) || empty($vault_gallery)) return; foreach ($vault_gallery as $affirm_id) { if (empty($affirm_id)) continue; echo wp_kses($this->render_woo_image_slide(sanitize_text_field($affirm_id)), $this->allowed_embed_html()); } } /** * Allowed HTML tags/attributes for protected image embeds (SDK div + iframe). */ private function allowed_embed_html() { return array( 'div' => array( 'data-affirmfirst' => true, 'data-thumb' => true, 'data-af-width' => true, 'data-af-height' => true, 'class' => true, 'style' => true, ), 'iframe' => array( 'src' => true, 'frameborder' => true, 'style' => true, 'loading' => true, 'width' => true, 'height' => true, ), ); } /** * Render a single WooCommerce gallery slide with a vault image */ private function render_woo_image_slide($affirm_id) { $render_mode = get_option('affirm_render_mode', 'sdk'); if ($render_mode === 'iframe') { $embed_url = affirm_get_backend_url() . '/sdk/embed/' . esc_attr($affirm_id); $image_html = sprintf( '', esc_url($embed_url) ); } else { $image_html = sprintf( '
', esc_attr($affirm_id) ); } return ''; } /** * Enqueue CSS for vault images inside WooCommerce product gallery. * Only loaded on single product pages that actually use a vault image. */ public function enqueue_woo_vault_styles() { if (!is_singular('product')) return; global $post; if (!$post) return; $affirm_id = get_post_meta($post->ID, 'affirm_featured_image_id', true); $vault_gallery = get_post_meta($post->ID, '_affirm_product_gallery', true); if (!$affirm_id && (!$vault_gallery || empty($vault_gallery))) return; wp_enqueue_style( 'affirm-woo-vault', AFFIRM_PLUGIN_URL . 'assets/css/woo-vault.css', array(), AFFIRM_VERSION ); } /** * Normalize a dimension value for safe output in an inline style attribute. * Delegates to the strict allow-list sanitizer in Affirm_Shortcode so that * size attributes can never be used to inject arbitrary CSS. */ private function normalize_dimension($value, $default = 'auto') { return Affirm_Shortcode::sanitize_dimension($value, $default); } /** * Get dimensions from WP size name or attributes, normalized with CSS units. * Attributes override size presets. Default: 100% width, auto height. */ private function get_dimensions($size, $attr) { $width = '100%'; $height = 'auto'; // Size presets (only if no explicit attr override) if (is_string($size)) { switch ($size) { case 'thumbnail': $width = '150px'; $height = '150px'; break; case 'medium': $width = '300px'; $height = 'auto'; break; case 'large': $width = '1024px'; $height = 'auto'; break; case 'full': default: $width = '100%'; $height = 'auto'; break; } } // Explicit attributes override size presets if (is_array($attr)) { if (isset($attr['width'])) $width = $this->normalize_dimension($attr['width'], $width); if (isset($attr['height'])) $height = $this->normalize_dimension($attr['height'], $height); } return array('width' => $width, 'height' => $height); } }