array( 'Authorization' => 'Bearer ' . $api_key, 'Accept' => 'application/json', ), 'timeout' => 30, )); if (is_wp_error($response)) return $response; $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { $msg = isset($body['error']) ? $body['error'] : 'API request failed'; return new WP_Error('api_error', $msg, array('status' => $code)); } return $body; } /** * Make an authenticated POST request to the frontend plugin API */ public static function post($endpoint, $data = array()) { $api_key = get_option('affirm_api_key'); if (!$api_key) return new WP_Error('no_api_key', 'API key not configured'); $url = affirm_get_frontend_url() . '/api/plugin/' . ltrim($endpoint, '/'); $response = wp_remote_post($url, array( 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode($data), 'timeout' => 30, )); if (is_wp_error($response)) return $response; $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { $msg = isset($body['error']) ? $body['error'] : 'API request failed'; return new WP_Error('api_error', $msg, array('status' => $code)); } return $body; } /** * Make an authenticated PUT request to the frontend plugin API */ public static function put($endpoint, $data = array()) { $api_key = get_option('affirm_api_key'); if (!$api_key) return new WP_Error('no_api_key', 'API key not configured'); $url = affirm_get_frontend_url() . '/api/plugin/' . ltrim($endpoint, '/'); $response = wp_remote_request($url, array( 'method' => 'PUT', 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode($data), 'timeout' => 30, )); if (is_wp_error($response)) return $response; $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { $msg = isset($body['error']) ? $body['error'] : 'API request failed'; return new WP_Error('api_error', $msg, array('status' => $code)); } return $body; } /** * Toggle image delivery on/off */ public static function toggle_image_delivery($image_id, $enabled) { return self::put('images/' . $image_id . '/delivery', array( 'enabled' => (bool) $enabled )); } /** * Get a short-lived backend JWT by exchanging the plugin API key. * Cached for 14 minutes (token expires in 15 min). * * Flow: API key → Frontend /api/plugin/token → backend JWT * Same pattern as mobile app: mobile JWT → /api/auth/mobile-token → backend JWT */ public static function get_backend_token() { // Return cached token if still valid (with 60s buffer) if (self::$backend_token && self::$backend_token_expiry > time() + 60) { return self::$backend_token; } $api_key = get_option('affirm_api_key'); if (!$api_key) return new WP_Error('no_api_key', 'API key not configured'); $url = affirm_get_frontend_url() . '/api/plugin/token'; $response = wp_remote_get($url, array( 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Accept' => 'application/json', ), 'timeout' => 15, )); if (is_wp_error($response)) return $response; $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { $msg = isset($body['error']) ? $body['error'] : 'Token exchange failed'; return new WP_Error('token_error', $msg, array('status' => $code)); } if (empty($body['token'])) { return new WP_Error('token_error', 'No token in response'); } // Cache for 14 minutes (token expires in 15) self::$backend_token = $body['token']; self::$backend_token_expiry = time() + 840; return self::$backend_token; } /** * Upload an image from URL to the aFFirmFirst vault. * Downloads the image locally, then uploads directly to the backend. */ public static function upload_from_url($image_url, $filename = '') { // Download the image to a temp file $tmp_file = download_url($image_url, 120); if (is_wp_error($tmp_file)) { return new WP_Error('download_error', 'Failed to download image: ' . $tmp_file->get_error_message()); } if (!$filename) { $filename = basename(wp_parse_url($image_url, PHP_URL_PATH)) ?: 'image.jpg'; } $mime_type = wp_check_filetype($filename)['type']; if (!$mime_type) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $tmp_file) ?: 'image/jpeg'; finfo_close($finfo); } $result = self::upload_file($tmp_file, $filename, $mime_type); // Clean up temp file wp_delete_file($tmp_file); return $result; } /** * Upload a file directly to the aFFirmFirst backend for processing. * Flow: WP Plugin → GET /api/plugin/token (API key → JWT) → POST backend/images/upload (JWT) * Same pattern as mobile app and frontend dashboard. */ public static function upload_file($file_path, $filename, $mime_type = '') { // Step 1: Get backend JWT $token = self::get_backend_token(); if (is_wp_error($token)) return $token; // Step 2: Upload directly to backend $url = affirm_get_backend_url() . '/images/upload'; if (!$mime_type) { $mime_type = wp_check_filetype($filename)['type']; if (!$mime_type) $mime_type = 'image/jpeg'; } $boundary = wp_generate_password(24, false); $body = ''; $body .= '--' . $boundary . "\r\n"; $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . "\r\n"; $body .= 'Content-Type: ' . $mime_type . "\r\n\r\n"; $body .= file_get_contents($file_path) . "\r\n"; // Extract and attach EXIF metadata if available $exif_data = self::extract_exif_metadata($file_path, $mime_type); if ($exif_data) { $body .= '--' . $boundary . "\r\n"; $body .= 'Content-Disposition: form-data; name="metadata"' . "\r\n"; $body .= 'Content-Type: application/json' . "\r\n\r\n"; $body .= wp_json_encode($exif_data) . "\r\n"; } $body .= '--' . $boundary . '--' . "\r\n"; $response = wp_remote_post($url, array( 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, 'Accept' => 'application/json', ), 'body' => $body, 'timeout' => 120, )); if (is_wp_error($response)) return $response; $code = wp_remote_retrieve_response_code($response); $data = json_decode(wp_remote_retrieve_body($response), true); if ($code >= 400) { $msg = isset($data['message']) ? $data['message'] : (isset($data['error']) ? $data['error'] : 'Upload failed'); return new WP_Error('upload_error', $msg, array('status' => $code)); } return $data; } /** * Extract EXIF metadata from an image file * Returns associative array matching the backend ImageMetadata schema, or null */ public static function extract_exif_metadata($file_path, $mime_type = '') { // exif_read_data only works with JPEG/TIFF if (!function_exists('exif_read_data')) return null; if ($mime_type && !in_array($mime_type, array('image/jpeg', 'image/tiff'))) return null; $exif = @exif_read_data($file_path, 'ANY_TAG', true); if (!$exif) return null; $metadata = array(); // Camera make + model $make = isset($exif['IFD0']['Make']) ? trim($exif['IFD0']['Make']) : ''; $model = isset($exif['IFD0']['Model']) ? trim($exif['IFD0']['Model']) : ''; if ($make && $model) { $metadata['camera'] = (strpos($model, $make) === 0) ? $model : "$make $model"; } elseif ($model) { $metadata['camera'] = $model; } elseif ($make) { $metadata['camera'] = $make; } // Lens if (!empty($exif['EXIF']['UndefinedTag:0xA434'])) { $metadata['lens'] = $exif['EXIF']['UndefinedTag:0xA434']; } // ISO if (!empty($exif['EXIF']['ISOSpeedRatings'])) { $metadata['iso'] = (int) $exif['EXIF']['ISOSpeedRatings']; } // Aperture (FNumber) if (!empty($exif['EXIF']['FNumber'])) { $fnum = self::exif_rational_to_float($exif['EXIF']['FNumber']); if ($fnum > 0) { $metadata['aperture'] = 'f/' . round($fnum, 1); } } // Shutter speed (ExposureTime) if (!empty($exif['EXIF']['ExposureTime'])) { $metadata['shutterSpeed'] = $exif['EXIF']['ExposureTime'] . 's'; } // Focal length if (!empty($exif['EXIF']['FocalLength'])) { $fl = self::exif_rational_to_float($exif['EXIF']['FocalLength']); if ($fl > 0) { $metadata['focalLength'] = round($fl) . 'mm'; } } // Date taken $date = ''; if (!empty($exif['EXIF']['DateTimeOriginal'])) { $date = $exif['EXIF']['DateTimeOriginal']; } elseif (!empty($exif['EXIF']['DateTimeDigitized'])) { $date = $exif['EXIF']['DateTimeDigitized']; } elseif (!empty($exif['IFD0']['DateTime'])) { $date = $exif['IFD0']['DateTime']; } if ($date) { $metadata['dateTaken'] = preg_replace('/^(\d{4}):(\d{2}):(\d{2})/', '$1-$2-$3', $date); } // Device if ($make) $metadata['device'] = $make; // Software if (!empty($exif['IFD0']['Software'])) { $metadata['software'] = $exif['IFD0']['Software']; } // GPS if (!empty($exif['GPS']['GPSLatitude']) && !empty($exif['GPS']['GPSLongitude'])) { $lat = self::gps_to_decimal($exif['GPS']['GPSLatitude'], isset($exif['GPS']['GPSLatitudeRef']) ? $exif['GPS']['GPSLatitudeRef'] : 'N'); $lng = self::gps_to_decimal($exif['GPS']['GPSLongitude'], isset($exif['GPS']['GPSLongitudeRef']) ? $exif['GPS']['GPSLongitudeRef'] : 'E'); if ($lat !== null && $lng !== null) { $metadata['gps'] = array('lat' => $lat, 'lng' => $lng); } } // Flash if (isset($exif['EXIF']['Flash'])) { $metadata['flash'] = (string) $exif['EXIF']['Flash']; } // White balance if (isset($exif['EXIF']['WhiteBalance'])) { $metadata['whiteBalance'] = (string) $exif['EXIF']['WhiteBalance']; } return !empty($metadata) ? $metadata : null; } /** * Parse an EXIF rational value (e.g. "24/1") to a float. * Returns 0.0 for malformed input or a zero denominator so callers never * hit a division-by-zero from corrupt metadata. */ private static function exif_rational_to_float($val) { if (is_array($val)) return 0.0; $val = (string) $val; if (strpos($val, '/') !== false) { $parts = explode('/', $val); if (count($parts) !== 2 || !is_numeric($parts[0]) || !is_numeric($parts[1])) { return 0.0; } $denominator = (float) $parts[1]; if ($denominator == 0.0) return 0.0; return (float) $parts[0] / $denominator; } return is_numeric($val) ? (float) $val : 0.0; } /** * Convert GPS DMS (degrees/minutes/seconds) array to decimal */ private static function gps_to_decimal($coord, $ref) { if (!is_array($coord) || count($coord) < 3) return null; $degrees = self::exif_rational_to_float($coord[0]); $minutes = self::exif_rational_to_float($coord[1]); $seconds = self::exif_rational_to_float($coord[2]); $decimal = $degrees + ($minutes / 60) + ($seconds / 3600); if ($ref === 'S' || $ref === 'W') $decimal *= -1; return round($decimal, 6); } /** * Get a signed image URL for an image * Uses local cache to avoid excessive API calls */ public static function get_signed_url($image_id, $ttl = null) { if (!$ttl) { $ttl = get_option('affirm_signed_url_ttl', 2592000); } global $wpdb; $table = $wpdb->prefix . 'affirm_signed_urls'; // Renewal buffer: regenerate if URL expires within 1 hour (handles WP page caching) $buffer = 3600; // Check cache — must be valid for at least $buffer seconds. // Custom cache table; $table is built from $wpdb->prefix (safe) and table names cannot be placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $cached = $wpdb->get_row($wpdb->prepare( "SELECT signed_url, expires_at FROM $table WHERE image_id = %s AND expires_at > %d ORDER BY expires_at DESC LIMIT 1", $image_id, time() + $buffer )); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ($cached) { return $cached->signed_url; } // Generate new signed URL via frontend plugin API $result = self::get("sign/{$image_id}", array('ttl' => $ttl)); if (is_wp_error($result)) return ''; $url = isset($result['url']) ? $result['url'] : ''; $expires = isset($result['expiresAt']) ? $result['expiresAt'] : (time() + $ttl); if ($url) { // Custom cache table insert; not applicable to object caching. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->insert($table, array( 'image_id' => $image_id, 'signed_url' => $url, 'expires_at' => $expires, )); // Cleanup expired entries. $table is built from $wpdb->prefix (safe); table names cannot be placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query($wpdb->prepare( "DELETE FROM $table WHERE expires_at < %d", time() )); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } return $url; } }