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 '' . $image_html . '
';
}
/**
* 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);
}
}