-affirmfirst_.jpg` * - post meta `_affirm_image_id` holds the vault image ID for exact lookups * * Because the proxy is a real attachment, every picker in WordPress and in every * builder lists it automatically. On the frontend, Affirm_Frontend_Renderer * swaps whatever was rendered from that attachment for the encrypted SDK canvas, * and the proxy is hidden before it can paint. * * The original is never stored in WordPress. The proxy is a thumbnail-sized * preview — the same trade-off the picker in any hosted media service makes. */ class Affirm_Media_Library { /** * Filename marker that ties a WP attachment to a vault image. * Keep in sync with assets/js/native-images.js. */ const MARKER = 'affirmfirst_'; /** Attachment meta holding the vault image ID. */ const META_KEY = '_affirm_image_id'; /** * Images published per sync pass. Each one is an HTTP download plus WP's own * thumbnail generation, so passes are capped and the caller chains them. */ const SYNC_BATCH = 20; /** attachment ID => vault image ID, resolved once per request. */ private static $lookup = array(); /** Vault image IDs whose proxy was actually used while rendering this page. */ private static $rendered = array(); public function __construct() { add_action('wp_ajax_affirm_sync_media_library', array($this, 'ajax_sync')); add_action('wp_ajax_affirm_media_library_status', array($this, 'ajax_status')); // A protected image must be recognisable at a glance in the media list. add_filter('attachment_fields_to_edit', array($this, 'attachment_notice'), 10, 2); } /** * Filename for a proxy attachment. * * The merchant's own filename goes in FRONT of the marker so the image is * searchable by name in every picker, and the vault ID stays the last token * before the extension — which is what the frontend scanner reads. * * The `.jpg` here is only a starting point: WordPress verifies the real image * type on sideload and corrects the extension (a WebP preview lands as * `.webp`). Everything downstream keys off the marker, never the extension. */ public static function proxy_filename($vault_id, $title) { $base = preg_replace('/\.[a-z0-9]{2,5}$/i', '', (string) $title); $slug = sanitize_title($base); $slug = trim(substr($slug, 0, 60), '-'); return ($slug ? $slug . '-' : '') . self::MARKER . $vault_id . '.jpg'; } /** * The proxy attachment for a vault image, or 0 when it has none yet. */ public static function find_attachment($vault_id) { $vault_id = (string) $vault_id; if ($vault_id === '') return 0; $found = get_posts(array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_key' => self::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $vault_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'no_found_rows' => true, 'suppress_filters' => true, )); return $found ? (int) $found[0] : 0; } /** * Every vault image that already has a proxy, as vault ID => attachment ID. * * Resolved in two queries no matter how large the library is. Asking * find_attachment() once per image instead would cost one query per vault * image on every sync pass. */ public static function published_ids() { $attachment_ids = get_posts(array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => self::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'no_found_rows' => true, 'suppress_filters' => true, 'update_post_term_cache' => false, )); if (empty($attachment_ids)) return array(); // WP_Query does not prime the meta cache for an IDs-only result, so warm // it explicitly and read every value from memory. update_meta_cache('post', $attachment_ids); $map = array(); foreach ($attachment_ids as $attachment_id) { $vault_id = get_post_meta($attachment_id, self::META_KEY, true); if (is_string($vault_id) && $vault_id !== '') { $map[$vault_id] = (int) $attachment_id; self::$lookup[(int) $attachment_id] = $vault_id; } } return $map; } /** * The vault image ID behind an attachment, or '' when it is an ordinary * WordPress image. Memoised because the frontend asks this for every single * image on the page. */ public static function image_id_for($attachment_id) { $attachment_id = (int) $attachment_id; if (!$attachment_id) return ''; if (!isset(self::$lookup[$attachment_id])) { $vault_id = get_post_meta($attachment_id, self::META_KEY, true); self::$lookup[$attachment_id] = is_string($vault_id) ? $vault_id : ''; } return self::$lookup[$attachment_id]; } /** * Record that a protected image was used on the page being rendered. The * frontend prints these IDs so the client-side scanner can match a builder's * own markup EXACTLY, instead of guessing the ID out of a filename that * WordPress may have decorated with a size suffix. */ public static function mark_rendered($vault_id) { $vault_id = (string) $vault_id; if ($vault_id !== '') self::$rendered[$vault_id] = true; } /** Vault image IDs used on this page, for the frontend scanner. */ public static function rendered_ids() { return array_keys(self::$rendered); } /** * Create the proxy attachment for a vault image, or return the existing one. * * @param array $image Vault image as returned by /api/plugin/images. * @return int|WP_Error Attachment ID. */ public static function ensure_attachment($image) { $vault_id = isset($image['id']) ? sanitize_text_field($image['id']) : ''; if (!$vault_id) { return new WP_Error('affirm_no_image_id', 'Vault image ID missing'); } $existing = self::find_attachment($vault_id); if ($existing) return $existing; // No preview means there is nothing to put in the media library yet — // the vault is still processing the upload. The next sync picks it up. $preview = isset($image['thumbnailUrl']) ? $image['thumbnailUrl'] : ''; if (!$preview) { return new WP_Error('affirm_no_preview', 'Vault preview not ready'); } $title = isset($image['originalFilename']) && $image['originalFilename'] ? $image['originalFilename'] : $vault_id; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $tmp = download_url($preview, 60); if (is_wp_error($tmp)) return $tmp; $file = array( 'name' => self::proxy_filename($vault_id, $title), 'tmp_name' => $tmp, ); $attachment_id = media_handle_sideload($file, 0, $title, array( 'post_excerpt' => 'aFFirmFirst protected image. The storefront renders the encrypted original; this file is only a preview for the media picker.', )); if (is_wp_error($attachment_id)) { // media_handle_sideload only cleans up after itself on success. if (file_exists($tmp)) wp_delete_file($tmp); return $attachment_id; } update_post_meta($attachment_id, self::META_KEY, $vault_id); update_post_meta($attachment_id, '_wp_attachment_image_alt', $title); self::$lookup[(int) $attachment_id] = $vault_id; return (int) $attachment_id; } /** * Publish one capped batch of vault images into the media library. * * @param array $images Vault images from /api/plugin/images. * @param int $limit Maximum to publish in this pass. * @return array added, failed, remaining, total */ public static function sync_batch($images, $limit = self::SYNC_BATCH) { if (!is_array($images)) $images = array(); $published = self::published_ids(); $pending = array(); foreach ($images as $image) { if (empty($image['id'])) continue; if (isset($published[$image['id']])) continue; $pending[] = $image; } $batch = array_slice($pending, 0, max(1, (int) $limit)); $added = 0; $failed = 0; foreach ($batch as $image) { // One unreachable preview must not stop the rest of the batch. if (is_wp_error(self::ensure_attachment($image))) { $failed++; } else { $added++; } } return array( 'added' => $added, 'failed' => $failed, 'remaining' => count($pending) - count($batch), 'total' => count($images), ); } /** How many of the given vault images already have a proxy attachment. */ public static function count_in_vault($images) { $published = self::published_ids(); $count = 0; foreach ((array) $images as $image) { if (!empty($image['id']) && isset($published[$image['id']])) $count++; } return $count; } /** * AJAX: publish the next batch. Called repeatedly by the admin screen until * `remaining` reaches zero, so a large vault needs no background job. */ public function ajax_sync() { check_ajax_referer('affirm_admin_nonce', 'nonce'); if (!current_user_can('upload_files')) { wp_send_json_error(array('message' => 'Unauthorized')); } $images = Affirm_Api::get('images'); if (is_wp_error($images)) { wp_send_json_error(array('message' => $images->get_error_message())); } $result = self::sync_batch($images); // Counted against the vault, not against every proxy in the library, so // the figure still means "images you can use" after a vault deletion. $result['published'] = self::count_in_vault($images); wp_send_json_success($result); } /** AJAX: how much of the vault is already in the media library. */ public function ajax_status() { check_ajax_referer('affirm_admin_nonce', 'nonce'); if (!current_user_can('upload_files')) { wp_send_json_error(array('message' => 'Unauthorized')); } $images = Affirm_Api::get('images'); if (is_wp_error($images)) { wp_send_json_error(array('message' => $images->get_error_message())); } $total = is_array($images) ? count($images) : 0; $published = self::count_in_vault($images); wp_send_json_success(array( 'total' => $total, 'published' => $published, 'remaining' => max(0, $total - $published), )); } /** * Label proxies in the media modal / attachment details so nobody mistakes * the low-resolution preview for the image the visitor will see. */ public function attachment_notice($form_fields, $post) { $vault_id = self::image_id_for(isset($post->ID) ? $post->ID : 0); if (!$vault_id) return $form_fields; $form_fields['affirm_protected'] = array( 'label' => 'aFFirmFirst', 'input' => 'html', 'html' => '

Protected image

' . '

Use this image anywhere. Visitors get the encrypted canvas, not this preview.

', ); return $form_fields; } }