register_ajax_handlers(); // Clean up stored original files when their attachment is deleted. add_action( 'delete_attachment', array( $this, 'delete_stored_original' ) ); // Try to initialize Image_Processor try { $this->image_processor = new Image_Processor(); } catch ( \Exception $e ) { // Don't throw - we'll check for null in handle_upload } } /** * Register AJAX action handlers. * * @since 1.0.0 */ private function register_ajax_handlers() { // Register AJAX action for authenticated users only. add_action( 'wp_ajax_gatewell_upload_image', array( $this, 'handle_upload' ) ); // Register AJAX handler for fetching applicable upload rule. add_action( 'wp_ajax_gatewell_get_upload_rule', array( $this, 'handle_get_upload_rule' ) ); } /** * Handle AJAX request to get the applicable upload rule for a context. * * Returns the matched rule (or global defaults) as JSON. * * @since 1.2.0 */ public function handle_get_upload_rule() { // Verify nonce. if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'gatewell_nonce' ) ) { wp_send_json_error( array( 'message' => 'Security check failed.' ) ); } // Check capability. if ( ! current_user_can( 'upload_files' ) ) { wp_send_json_error( array( 'message' => 'Insufficient permissions.' ) ); } // Get context from AJAX request. $context = \GATEWELL_Context_Detector::get_ajax_context(); // Find matching rule. $rule = \GATEWELL_Rule_Matcher::find_matching_rule( $context ); wp_send_json_success( array( 'rule' => $rule, 'context' => $context, ) ); } /** * Handle image upload AJAX request. * * Main entry point for AJAX upload requests. Validates request, processes image, * creates attachment, and returns JSON response. * * @since 1.0.0 */ public function handle_upload() { // Wrap everything in try-catch to ensure we always send a JSON response try { // Add this debug line at the very beginning // Verify nonce. if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'gatewell_nonce' ) ) { $this->send_error( 'Security check failed.' ); } // Step 2 - Check user capability. if ( ! current_user_can( 'upload_files' ) ) { $this->send_error( 'You do not have permission to upload files.' ); } // Step 3 - Verify POST data. if ( empty( $_POST['image'] ) || empty( $_POST['filename'] ) ) { $this->send_error( 'Missing required upload data.' ); } // Base64 payload. sanitize_text_field() must NOT be used here: it // strips characters that are legal in base64 and silently corrupts // the image. The value is instead validated as strict base64 a few // lines below (and decoded/re-encoded by Image_Processor), which is // a stronger check than sanitising it as text would be. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated against a strict base64 whitelist regex below; sanitising as text would corrupt the payload. $image_data = wp_unslash( $_POST['image'] ); // Validate base64 format early (before decoding in Image_Processor) // Remove data URI prefix if present (e.g., "data:image/png;base64,") $base64_data = $image_data; if ( strpos( $base64_data, ',' ) !== false ) { $base64_data = substr( $base64_data, strpos( $base64_data, ',' ) + 1 ); } // Validate base64 format using regex if ( ! preg_match( '/^[A-Za-z0-9+\/]*={0,2}$/', $base64_data ) ) { $this->send_error( 'Invalid image data format.' ); } $filename = sanitize_text_field( wp_unslash( $_POST['filename'] ) ); $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $alt_text = isset( $_POST['alt_text'] ) ? sanitize_text_field( wp_unslash( $_POST['alt_text'] ) ) : ''; $caption = isset( $_POST['caption'] ) ? sanitize_text_field( wp_unslash( $_POST['caption'] ) ) : ''; $description = isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : ''; $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : ''; $original_mime_type = isset( $_POST['original_mime_type'] ) ? sanitize_text_field( wp_unslash( $_POST['original_mime_type'] ) ) : ''; // Step 3.5 - Validate the declared MIME type against the allowed formats setting. // This value is client-supplied and decides which processing branch runs below // (SVG vs. raster vs. non-image), so it must be checked before it is trusted for // routing. Without this, a hand-crafted POST could reach the SVG branch even when // the administrator has SVG disabled in settings. if ( empty( $original_mime_type ) || ! Format_Validator::is_mime_type_allowed( $original_mime_type ) ) { $this->send_error( 'File type not allowed.' ); } // Step 4 - Verify user can edit post (if post_id is provided). if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) { $this->send_error( 'You do not have permission to edit this post.' ); } // Step 5 - Validate filename. try { $filename = $this->validate_filename( $filename ); } catch ( \Exception $e ) { $this->send_error( $e->getMessage() ); } // Step 5.5 - Validate form fields (alt text, etc.). // Note: Only validate image-specific fields for images $file_category = Format_Validator::get_file_type_category( $original_mime_type ); // Estimate file size from base64 data (3 bytes per 4 base64 chars). $estimated_file_size = (int) ( strlen( $base64_data ) * 3 / 4 ); try { $validation_context = array( 'file_size' => $estimated_file_size, 'file_category' => $file_category, 'post_id' => $post_id, 'post_type' => $post_id > 0 ? get_post_type( $post_id ) : null, ); $this->validate_form_fields( $alt_text, $file_category, $caption, $description, $validation_context, $title ); } catch ( \Exception $e ) { $this->send_error( $e->getMessage() ); } // Step 6.5 - Validate File Type Controls. // Resolve the upload location (media-library, featured-image, etc.) the same way // handle_get_upload_rule() does. The uploader posts it as `upload_context`, and // the detector whitelists it against GATEWELL_Rule_Storage::VALID_CONTEXTS. $ajax_context = \GATEWELL_Context_Detector::get_ajax_context(); $upload_context = array( 'user_id' => get_current_user_id(), 'post_id' => $post_id, 'location' => $ajax_context['context'], 'filename' => $filename, 'file_size' => $estimated_file_size, 'file_category' => $file_category, 'post_type' => $post_id > 0 ? get_post_type( $post_id ) : null, ); $validation_result = \GATEWELL_File_Type_Controls_Validator::validate_upload( $original_mime_type, $file_category, $upload_context ); if ( is_wp_error( $validation_result ) ) { $this->send_error( $validation_result->get_error_message() ); } // Step 7 - Process file based on type. $processed_files = null; try { // Route based on file category. if ( $file_category === 'image' ) { // Check if this is an SVG file. if ( Format_Validator::is_svg( $original_mime_type ) ) { $processed_files = $this->process_svg_upload( $image_data ); } elseif ( $this->image_processor ) { $processed_files = $this->image_processor->process_upload( $image_data, $original_mime_type ); } else { // No GD extension on this server, so there is no raster // pipeline to run. Store the bytes the browser sent instead // of rejecting the upload: the crop was already applied // client-side by Cropper.js and the canvas re-encode is // itself lossy, so what arrives here is a finished image. // Only server-side resize, recompression and format // conversion are skipped; every alt text, title, filename, // format and dimension rule has already been enforced above. $processed_files = $this->process_image_passthrough( $image_data ); } } } catch ( \Exception $e ) { $this->send_error( $e->getMessage() ); } // This build only processes images; Format_Validator rejects everything else // long before this point. Fail loudly rather than continuing with no result. if ( ! is_array( $processed_files ) || empty( $processed_files['compressed'] ) ) { $this->send_error( sprintf( /* translators: %s: file type category (image, video, audio, document, archive). */ __( 'This file type (%s) cannot be processed by Gatewell.', 'gatewell' ), $file_category ) ); } // Step 6 - Create attachment. try { $attachment_id = $this->create_attachment( $processed_files['compressed'], $filename, $alt_text, $caption, $description, $post_id, $title ); } catch ( \Exception $e ) { $this->send_error( 'Failed to create attachment: ' . $e->getMessage() ); } // NEW: Record optimization event for ImageAudit integration (Phase 2) try { $this->record_optimization_event( $attachment_id, $processed_files, $filename, array( 'alt_text' => $alt_text, 'caption' => $caption, 'description' => $description, 'post_id' => $post_id, 'file_category' => $file_category, ) ); } catch ( \Exception $e ) { // Don't fail the upload if recording fails, just log it. Debug::log( 'Failed to record optimization: ' . $e->getMessage(), 'Gatewell' ); } // Step 8 - Handle original file storage based on settings. try { $this->handle_original_storage( $processed_files['original'], $filename, $attachment_id ); } catch ( \Exception $e ) { // Don't fail the upload if original storage fails, just log it. } // Step 7 - Return success. $image_url = wp_get_attachment_image_url( $attachment_id, 'full' ); $this->send_success( array( 'attachment_id' => $attachment_id, 'image_url' => $image_url, 'post_id' => $post_id, 'message' => 'Image uploaded successfully.', ) ); } catch ( \Exception $e ) { // Catch any uncaught exceptions $this->send_error( 'An unexpected error occurred: ' . $e->getMessage() ); } catch ( \Error $e ) { // Catch fatal errors (PHP 7+) Debug::log( 'Fatal upload error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(), 'Gatewell' ); $this->send_error( 'A fatal error occurred: ' . $e->getMessage() ); } } /** * Validate filename format. * * Filename must contain only alphanumeric characters, spaces, hyphens, and underscores. * Spaces will be converted to hyphens for WordPress compatibility. * Will be converted to lowercase automatically. * * @param string $filename Filename to validate. * @return string Validated and sanitized filename. * @throws \Exception If filename is invalid. * @since 1.0.0 */ private function validate_filename( $filename ) { // Check if empty. if ( empty( $filename ) ) { throw new \Exception( 'Filename is required.' ); } // Convert to lowercase. $filename = strtolower( $filename ); // Check max length. if ( strlen( $filename ) > GATEWELL_MAX_FILENAME_LENGTH ) { throw new \Exception( 'Filename must be ' . (int) GATEWELL_MAX_FILENAME_LENGTH . ' characters or less.' ); } // Check format (alphanumeric, spaces, hyphens, underscores only). if ( ! preg_match( '/^[a-z0-9_\s-]+$/', $filename ) ) { throw new \Exception( 'Filename can only contain letters, numbers, spaces, hyphens, and underscores.' ); } // Convert spaces to hyphens for WordPress compatibility. $filename = preg_replace( '/\s+/', '-', $filename ); return $filename; } /** * Validate form fields based on plugin settings and file type. * * Checks alt text if it's configured as required (images only). * Also checks conditional metadata requirements (Phase 3 & 4 PRO). * * @param string $alt_text Alt text to validate. * @param string $file_category File type category (image, video, audio, document, archive). * @param string $caption Caption text. * @param string $description Description text. * @param array $context Validation context (file_size, post_id, post_type). * @return void * @throws \Exception If validation fails. * @since 1.0.0 */ private function validate_form_fields( $alt_text = '', $file_category = 'image', $caption = '', $description = '', $context = array(), $title = '' ) { // Get form field settings. $default_form_fields = \GATEWELL_Feature_Flags::get_form_field_defaults(); $form_fields = get_option( 'gatewell_form_fields', $default_form_fields ); if ( ! is_array( $form_fields ) ) { $form_fields = $default_form_fields; } $form_fields = wp_parse_args( $form_fields, $default_form_fields ); // Get field minimum character requirements. $default_field_minimums = \GATEWELL_Feature_Flags::get_field_minimum_defaults(); $field_minimums = get_option( 'gatewell_field_minimums', $default_field_minimums ); if ( ! is_array( $field_minimums ) ) { $field_minimums = $default_field_minimums; } $field_minimums = wp_parse_args( $field_minimums, $default_field_minimums ); // Validate alt text if required (images only). if ( $file_category === 'image' ) { $alt_text_config = $form_fields['alt_text'] ?? array(); if ( isset( $alt_text_config['show'] ) && $alt_text_config['show'] && isset( $alt_text_config['required'] ) && $alt_text_config['required'] ) { $alt_text = trim( $alt_text ); if ( empty( $alt_text ) ) { throw new \Exception( 'Alt text is required.' ); } $min_chars = $field_minimums['alt_text'] ?? 5; if ( strlen( $alt_text ) < $min_chars ) { throw new \Exception( sprintf( 'Alt text must be at least %d characters.', (int) $min_chars ) ); } } } // Validate title if required. $title_config = $form_fields['title'] ?? array(); if ( isset( $title_config['show'] ) && $title_config['show'] && isset( $title_config['required'] ) && $title_config['required'] ) { $title = trim( $title ); if ( empty( $title ) ) { throw new \Exception( 'Title is required.' ); } $min_chars = $field_minimums['title'] ?? 5; if ( strlen( $title ) < $min_chars ) { throw new \Exception( sprintf( 'Title must be at least %d characters.', (int) $min_chars ) ); } } // Check field uniqueness if enabled. if ( get_option( 'gatewell_require_unique_fields', true ) ) { $threshold = (int) get_option( 'gatewell_uniqueness_threshold', 75 ); $pairs = array( array( 'Alt Text', $alt_text, 'Title', $title ), array( 'Alt Text', $alt_text, 'Description', $description ), array( 'Title', $title, 'Description', $description ), ); foreach ( $pairs as $pair ) { list( $label_a, $val_a, $label_b, $val_b ) = $pair; if ( empty( trim( $val_a ) ) || empty( trim( $val_b ) ) ) { continue; } $similarity = $this->jaccard_similarity( $val_a, $val_b ); if ( $similarity >= $threshold ) { throw new \Exception( esc_html( sprintf( /* translators: 1: first field name, 2: second field name, 3: similarity percentage */ __( '%1$s and %2$s are too similar (%3$d%% match). Please make them more distinct.', 'gatewell' ), $label_a, $label_b, $similarity ) ) ); } } } } /** * Validate metadata fields against requirements. * * Helper method to check if required metadata fields are provided. * * @param string $alt_text Alt text value. * @param string $caption Caption value. * @param string $description Description value. * @param array $requirements Required fields (e.g., ['alt_text' => true, 'caption' => true]). * @param string $requirement_type Type of requirement for error message. * @return void * @throws \Exception If required field is missing. * @since 1.5.0 */ private function validate_metadata_requirements( $alt_text, $caption, $description, $requirements, $requirement_type = 'requirements' ) { if ( empty( $requirements ) ) { return; } // Only validate fields that are actually shown in the form. $default_form_fields = \GATEWELL_Feature_Flags::get_form_field_defaults(); $form_fields = get_option( 'gatewell_form_fields', $default_form_fields ); if ( ! is_array( $form_fields ) ) { $form_fields = $default_form_fields; } $field_values = array( 'alt_text' => trim( $alt_text ), 'caption' => trim( $caption ), 'description' => trim( $description ), ); foreach ( $requirements as $field => $required ) { // Skip validation if the field is not shown to the user. if ( isset( $form_fields[ $field ] ) && empty( $form_fields[ $field ]['show'] ) ) { continue; } if ( $required && isset( $field_values[ $field ] ) && empty( $field_values[ $field ] ) ) { $field_label = ucwords( str_replace( '_', ' ', $field ) ); throw new \Exception( esc_html( sprintf( /* translators: %1$s: field name, %2$s: requirement type */ __( '%1$s is required by %2$s.', 'gatewell' ), $field_label, $requirement_type ) ) ); } } } /** * Store a raster image without any server-side processing. * * The fallback used when the GD extension is unavailable. Image_Processor * cannot be constructed without GD, so there is nothing to resize, compress * or convert with — but the payload arriving here has already been cropped * and re-encoded by the browser, so it is a usable image, not a raw original. * * The MIME type is taken from finfo_buffer() rather than from the caller: a * canvas asked to export a format the browser cannot encode silently returns * PNG, so the client-supplied type is not reliable here. Sniffing the decoded * bytes before anything is written means the file is created once, already at * the right extension, with no temporary name to rename afterwards. The * detected type is checked against the allowed formats setting, exactly as * the processed path would. * * @param string $image_data Base64 encoded image data. * @return array Array with 'compressed' and 'original' file paths. * @throws \Exception On processing error. * @since 1.6.3 */ private function process_image_passthrough( $image_data ) { $decoded_data = base64_decode( preg_replace( '#^data:[^;]+;base64,#', '', $image_data ), true ); if ( false === $decoded_data || '' === $decoded_data ) { throw new \Exception( 'Invalid image data.' ); } // Trust the bytes, not the caller, for the real type — and read it from // memory so the file only ever has to be written once. $finfo = finfo_open( FILEINFO_MIME_TYPE ); $detected_mime = finfo_buffer( $finfo, $decoded_data ); finfo_close( $finfo ); if ( ! is_string( $detected_mime ) || 0 !== strpos( $detected_mime, 'image/' ) ) { throw new \Exception( 'Invalid image file. The uploaded data is not a recognised image.' ); } if ( ! Format_Validator::is_mime_type_allowed( $detected_mime ) ) { throw new \Exception( 'File type not allowed.' ); } $upload_dir = wp_upload_dir(); $temp_dir = $upload_dir['basedir'] . '/' . GATEWELL_TEMP_FOLDER; if ( ! file_exists( $temp_dir ) ) { wp_mkdir_p( $temp_dir ); } // Decoded upload bytes land here; keep them off the web. self::protect_directory( $temp_dir ); // The extension its detected type calls for, so create_attachment() and // WordPress agree on what this file is. $extension = Format_Validator::get_extension_from_mime_extended( $detected_mime ); $temp_file_path = $temp_dir . '/' . wp_generate_password( 12, false ) . '.' . $extension; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents if ( false === file_put_contents( $temp_file_path, $decoded_data ) ) { throw new \Exception( 'Failed to save image.' ); } // Nothing was compressed, so both paths point at the same file. return array( 'compressed' => $temp_file_path, 'original' => $temp_file_path, ); } /** * Process SVG upload with sanitization. * * Re-checks the allowed formats setting before doing any work: the caller passes a * client-supplied MIME type, so this method never assumes the caller validated it. * Unlike the raster path, there is no finfo re-detection downstream, so the bytes are * also required to parse as XML with an root element before anything is written * to disk. * * @param string $svg_data Base64 encoded SVG data. * @return array Array with 'compressed' and 'original' file paths. * @throws \Exception On processing error. * @since 1.1.0 */ private function process_svg_upload( $svg_data ) { // Defense in depth: SVG uploads must be enabled in the allowed formats setting. if ( ! Format_Validator::is_mime_type_allowed( 'image/svg+xml' ) ) { throw new \Exception( 'File type not allowed.' ); } // Sanitize SVG content. $sanitized_svg = SVG_Sanitizer::sanitize_svg_from_base64( $svg_data ); // Verify the sanitized payload really is an SVG document before writing it to disk. if ( ! $this->is_svg_document( $sanitized_svg ) ) { throw new \Exception( 'Invalid SVG file. The uploaded data is not a valid SVG document.' ); } // Create temporary file path. $upload_dir = wp_upload_dir(); $temp_dir = $upload_dir['basedir'] . '/' . GATEWELL_TEMP_FOLDER; // Create temp directory if it doesn't exist. if ( ! file_exists( $temp_dir ) ) { wp_mkdir_p( $temp_dir ); } // Decoded upload bytes and sanitized SVGs land here; keep them off the web. self::protect_directory( $temp_dir ); // Generate unique filename. $temp_filename = 'svg_' . wp_generate_password( 12, false ) . '.svg'; $temp_file_path = $temp_dir . '/' . $temp_filename; // Save sanitized SVG to temp file. if ( ! SVG_Sanitizer::save_svg_to_file( $sanitized_svg, $temp_file_path ) ) { throw new \Exception( 'Failed to save sanitized SVG.' ); } // For SVG, there's no compression, so "compressed" and "original" are the same. return array( 'compressed' => $temp_file_path, 'original' => $temp_file_path, ); } /** * Check that a string parses as XML with an root element. * * Used to confirm SVG payloads really are SVG documents before they are written to * disk, since the SVG path has no finfo-based MIME re-detection of its own. * * @param string $svg_content SVG markup to inspect. * @return bool True if the content parses with an svg root element, false otherwise. * @since 1.5.4 */ private function is_svg_document( $svg_content ) { if ( ! is_string( $svg_content ) || '' === trim( $svg_content ) ) { return false; } $previous_error_setting = libxml_use_internal_errors( true ); $dom = new \DOMDocument(); $dom->formatOutput = false; $dom->preserveWhiteSpace = true; $dom->strictErrorChecking = false; $loaded = $dom->loadXML( $svg_content ); libxml_clear_errors(); libxml_use_internal_errors( $previous_error_setting ); if ( ! $loaded || ! $dom->documentElement ) { return false; } return 'svg' === strtolower( $dom->documentElement->nodeName ); } /** * Create attachment post in media library. * * Moves processed image to uploads directory and creates attachment post * with metadata. Optionally attaches to a post via post_parent. * * @param string $file_path Path to processed image file. * @param string $filename Base filename (without extension). * @param string $alt_text Alt text for image (optional). * @param string $caption Caption for image (optional). * @param string $description Description for image (optional). * @param int $post_id Post ID to attach to (0 for unattached). * @return int Attachment post ID. * @throws \Exception On attachment creation failure. * @since 1.0.0 */ private function create_attachment( $file_path, $filename, $alt_text = '', $caption = '', $description = '', $post_id = 0, $title = '' ) { // Get MIME type from file. $finfo = finfo_open( FILEINFO_MIME_TYPE ); $mime_type = finfo_file( $finfo, $file_path ); finfo_close( $finfo ); // Get uploads directory. $upload_dir = wp_upload_dir(); // Get extension from the processed file path. $path_info = pathinfo( $file_path ); $extension = isset( $path_info['extension'] ) ? $path_info['extension'] : 'jpg'; // Create final filename with extension. $final_filename = $filename . '.' . $extension; // Use WordPress's built-in unique filename function to avoid collisions. $final_filename = wp_unique_filename( $upload_dir['path'], $final_filename ); // Move file to uploads directory. $target_path = $upload_dir['path'] . '/' . $final_filename; // Move file using WordPress filesystem API. global $wp_filesystem; if ( empty( $wp_filesystem ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if ( ! $wp_filesystem->move( $file_path, $target_path, true ) ) { throw new \Exception( 'Failed to move file to uploads directory.' ); } // Prepare attachment post data. $attachment = array( 'post_title' => ! empty( $title ) ? $title : $filename, 'post_content' => $description, // Description 'post_excerpt' => $caption, // Caption 'post_status' => 'inherit', 'post_mime_type' => $mime_type, 'post_parent' => $post_id, // Attach to post (0 = unattached). ); // Insert attachment post. $attachment_id = wp_insert_attachment( $attachment, $target_path, $post_id ); if ( is_wp_error( $attachment_id ) ) { throw new \Exception( 'Failed to create attachment: ' . esc_html( $attachment_id->get_error_message() ) ); } // Include image processing functions. require_once ABSPATH . 'wp-admin/includes/image.php'; // Generate attachment metadata. $attach_data = wp_generate_attachment_metadata( $attachment_id, $target_path ); // Update attachment metadata. wp_update_attachment_metadata( $attachment_id, $attach_data ); // Add alt text if provided. if ( ! empty( $alt_text ) ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text ); } // Add Gatewell processing metadata to prevent external plugins from re-processing. $cooperation_mode = Plugin_Compatibility::get_effective_mode(); update_post_meta( $attachment_id, '_gatewell_processed', array( 'version' => GATEWELL_VERSION, 'compressed' => Plugin_Compatibility::should_gatewell_compress(), 'format_conversion' => Plugin_Compatibility::should_gatewell_convert(), 'cooperation_mode' => $cooperation_mode, 'timestamp' => time(), ) ); // Add simple flag for external plugins to detect. update_post_meta( $attachment_id, '_gatewell_optimized', '1' ); return $attachment_id; } /** * Record optimization event in bridge table for ImageAudit integration. * * NEW: Phase 2 - Record optimization and emit action hook * * @param int $attachment_id Attachment ID. * @param array $processed_files Array with 'compressed' and 'original' file paths. * @param string $filename Base filename. * @return void * @since 2.0.0 (Phase 2) */ private function record_optimization_event( $attachment_id, $processed_files, $filename, $extra_data = array() ) { // Get attached file path from WordPress $attached_file = get_attached_file( $attachment_id ); if ( ! $attached_file ) { return; } // Calculate file sizes $original_file_size = 0; $optimized_file_size = 0; if ( file_exists( $processed_files['original'] ) ) { $original_file_size = filesize( $processed_files['original'] ); } if ( file_exists( $attached_file ) ) { $optimized_file_size = filesize( $attached_file ); } // Get full URL of the attached file $full_url = wp_get_attachment_url( $attachment_id ); // Build relative file path for bridge table (e.g., /2024/01/filename.jpg) $upload_dir = wp_upload_dir(); $relative_file_path = str_replace( $upload_dir['basedir'], '', $attached_file ); // Get MIME type and determine format $mime_type = get_post_mime_type( $attachment_id ); $output_format = 'unknown'; if ( strpos( $mime_type, 'jpeg' ) !== false ) { $output_format = 'jpeg'; } elseif ( strpos( $mime_type, 'png' ) !== false ) { $output_format = 'png'; } elseif ( strpos( $mime_type, 'webp' ) !== false ) { $output_format = 'webp'; } elseif ( strpos( $mime_type, 'gif' ) !== false ) { $output_format = 'gif'; } // Get compression quality from settings $compression_settings = get_option( 'gatewell_compression_quality', array( 'quality' => GATEWELL_DEFAULT_COMPRESSION_QUALITY ) ); $compression_quality = isset( $compression_settings['quality'] ) ? absint( $compression_settings['quality'] ) : GATEWELL_DEFAULT_COMPRESSION_QUALITY; // Determine if format was converted $format_converted = false; // Record the optimization in bridge table if ( class_exists( 'Gatewell\Migration' ) ) { Migration::record_optimization( array( 'attachment_id' => $attachment_id, 'file_path' => $relative_file_path, 'full_url' => $full_url, 'compression_quality' => $compression_quality, 'output_format' => $output_format, 'original_file_size' => $original_file_size, 'optimized_file_size' => $optimized_file_size, 'format_converted' => (bool) $format_converted, 'version_info' => array( 'gatewell_version' => GATEWELL_VERSION, 'php_version' => PHP_VERSION, 'timestamp' => current_time( 'mysql' ), ), ) ); Debug::log( 'Optimization recorded: ' . $relative_file_path, 'Gatewell' ); } // Emit action hook for external plugins (ImageAudit listens to this). // // Fired under both names: `image_lock_image_processed` is the legacy // name ImageAudit already hooks, `gatewell_image_processed` is the // forward-facing one. Same payload, so a listener on either sees the // identical event - do not hook both in one plugin or you will process // each upload twice. $gatewell_processed_event = array( 'attachment_id' => $attachment_id, 'file_path' => $relative_file_path, 'full_url' => $full_url, 'compression_quality' => $compression_quality, 'output_format' => $output_format, 'original_file_size' => $original_file_size, 'optimized_file_size' => $optimized_file_size, 'format_converted' => (bool) $format_converted, 'space_saved' => max( 0, $original_file_size - $optimized_file_size ), 'compression_ratio' => $original_file_size > 0 ? round( ( $original_file_size - $optimized_file_size ) / $original_file_size * 100, 2 ) : 0, ); do_action( 'gatewell_image_processed', $gatewell_processed_event ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Published integration contract. The ImageAudit plugin already listens on this legacy name; renaming it would silently break that integration on every install running both plugins. The prefixed `gatewell_image_processed` fires immediately above with an identical payload. do_action( 'image_lock_image_processed', $gatewell_processed_event ); Debug::log( 'Action hooks fired: gatewell_image_processed + image_lock_image_processed', 'Gatewell' ); // Log analytics event if ( class_exists( 'Gatewell\\Analytics\\Analytics_Logger' ) ) { $alt_text_ctx = isset( $extra_data['alt_text'] ) ? $extra_data['alt_text'] : ''; $caption_ctx = isset( $extra_data['caption'] ) ? $extra_data['caption'] : ''; $description_ctx = isset( $extra_data['description'] ) ? $extra_data['description'] : ''; $post_id_ctx = isset( $extra_data['post_id'] ) ? (int) $extra_data['post_id'] : 0; $file_cat = isset( $extra_data['file_category'] ) ? $extra_data['file_category'] : 'image'; $ai_transient = get_transient( 'gatewell_ai_used_' . $attachment_id ); $used_ai_alt = is_array( $ai_transient ) && ! empty( $ai_transient['alt'] ) ? 1 : 0; $used_ai_title_flag = is_array( $ai_transient ) && ! empty( $ai_transient['title'] ) ? 1 : 0; delete_transient( 'gatewell_ai_used_' . $attachment_id ); $upload_ctx = 'media-library'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- record_optimization_event() is private and is only reached from handle_upload(), which verifies the 'gatewell_nonce' nonce and the upload_files capability before any processing (see the nonce check near the top of handle_upload()). if ( isset( $_POST['upload_context'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Same verified call path as the isset() above. $upload_ctx = sanitize_text_field( wp_unslash( $_POST['upload_context'] ) ); } elseif ( $post_id_ctx > 0 ) { $upload_ctx = 'content-image'; } $logger = \Gatewell\Analytics\Analytics_Logger::get_instance(); $logger->log_upload_event( array( 'post_id' => $post_id_ctx, 'post_type' => $post_id_ctx > 0 ? (string) get_post_type( $post_id_ctx ) : '', 'attachment_id' => $attachment_id, 'upload_context' => $upload_ctx, 'user_id' => get_current_user_id(), 'file_type' => $file_cat, 'file_size_original' => $original_file_size, 'file_size_final' => $optimized_file_size, 'was_compressed' => $optimized_file_size < $original_file_size ? 1 : 0, 'was_rejected' => 0, 'alt_text_length' => strlen( $alt_text_ctx ), 'title_length' => strlen( $filename ), 'caption_length' => strlen( $caption_ctx ), 'description_length' => strlen( $description_ctx ), 'alt_text_quality_score' => $logger->calculate_text_quality_score( $alt_text_ctx, 'alt_text' ), 'title_quality_score' => $logger->calculate_text_quality_score( $filename, 'title' ), 'used_ai_alt_text' => $used_ai_alt, 'used_ai_title' => $used_ai_title_flag, ) ); } } /** * Handle original file storage based on settings. * * @param string $original_file Path to original file. * @param string $filename Base filename. * @param int $attachment_id Attachment ID of compressed file. * @throws \Exception On storage error. * @since 1.0.0 */ private function handle_original_storage( $original_file, $filename, $attachment_id ) { // Get storage settings. $storage_settings = get_option( 'gatewell_original_storage', array( 'method' => 'none' ) ); $storage_method = isset( $storage_settings['method'] ) ? $storage_settings['method'] : 'none'; switch ( $storage_method ) { case 'same_directory': $this->save_original_same_directory( $original_file, $filename, $attachment_id ); break; case 'dedicated_folder': $this->save_original_dedicated_folder( $original_file, $filename, $attachment_id ); break; case 'none': default: // Don't save original, just clean up. if ( file_exists( $original_file ) ) { wp_delete_file( $original_file ); } break; } } /** * Save original file in same directory as compressed file. * * Note: by design this option stores the untouched original inside the public media * tree (next to the attachment, as -original.), so the file is publicly * accessible at a predictable URL. Administrators choose this storage method to keep * originals alongside their attachments; use the dedicated folder method instead if the * originals must not be web-reachable. The path is recorded in the * `_gatewell_original_file` post meta so the file can be removed when the attachment * is deleted (see delete_stored_original()). * * @param string $original_file Path to original file. * @param string $filename Base filename. * @param int $attachment_id Attachment ID of compressed file. * @throws \Exception On save error. * @since 1.0.0 */ private function save_original_same_directory( $original_file, $filename, $attachment_id ) { // Get the compressed file path from attachment metadata. $compressed_path = get_attached_file( $attachment_id ); $path_info = pathinfo( $compressed_path ); // Create original filename with -original suffix. $original_filename = $path_info['filename'] . '-original.' . $path_info['extension']; $original_path = $path_info['dirname'] . '/' . $original_filename; // Copy original file to same directory using the WordPress filesystem API. global $wp_filesystem; if ( empty( $wp_filesystem ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if ( empty( $wp_filesystem ) || ! $wp_filesystem->copy( $original_file, $original_path, true ) ) { throw new \Exception( 'Failed to save original file in same directory.' ); } // Track the stored original so it can be deleted with the attachment. $this->register_stored_original( $attachment_id, $original_path ); // Clean up temp file. wp_delete_file( $original_file ); } /** * Save original file in dedicated folder. * * Originals are written to /uploads/gatewell-originals/YYYY/MM/ and the folder is * protected from direct web access with a .htaccess deny rule and an index.php stub. * The resulting path is recorded in the `_gatewell_original_file` post meta when an * attachment ID is known, so the file is removed when the attachment is deleted. * * @param string $original_file Path to original file. * @param string $filename Base filename. * @param int $attachment_id Attachment ID of compressed file (0 if unknown). * @throws \Exception On save error. * @since 1.0.0 */ private function save_original_dedicated_folder( $original_file, $filename, $attachment_id = 0 ) { // Get WordPress uploads directory. $upload_dir = wp_upload_dir(); $base_dir = $upload_dir['basedir']; // Create dedicated folder structure: /uploads/gatewell-originals/YYYY/MM/. $year = gmdate( 'Y' ); $month = gmdate( 'm' ); $storage_root = $base_dir . '/' . GATEWELL_ORIGINAL_STORAGE_FOLDER; $dedicated_dir = $storage_root . '/' . $year . '/' . $month; // Create directory if it doesn't exist. if ( ! file_exists( $dedicated_dir ) ) { wp_mkdir_p( $dedicated_dir ); } // Block direct web access to the originals folder. self::protect_directory( $storage_root ); // Get file extension from original file. $path_info = pathinfo( $original_file ); $extension = isset( $path_info['extension'] ) ? $path_info['extension'] : 'jpg'; // Create original filename. wp_unique_filename() is required here: unlike the // same-directory method, which derives its name from the attachment path // WordPress has already de-duplicated, this folder is keyed only on the // user-supplied base name and the current month — so two uploads called // "hero.jpg" in the same month would silently overwrite the first original. $original_filename = wp_unique_filename( $dedicated_dir, $filename . '-original.' . $extension ); $original_path = $dedicated_dir . '/' . $original_filename; // Copy original file to dedicated folder using the WordPress filesystem API. global $wp_filesystem; if ( empty( $wp_filesystem ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if ( empty( $wp_filesystem ) || ! $wp_filesystem->copy( $original_file, $original_path, true ) ) { throw new \Exception( 'Failed to save original file in dedicated folder.' ); } // Track the stored original so it can be deleted with the attachment. $this->register_stored_original( $attachment_id, $original_path ); // Clean up temp file. wp_delete_file( $original_file ); } /** * Record the path of a stored original on its attachment. * * Without this, originals written next to (or alongside) the attachment are orphaned: * nothing in the database references them, so they can never be cleaned up. * * @param int $attachment_id Attachment ID (0 if unknown - nothing is recorded). * @param string $original_path Absolute path to the stored original file. * @return void * @since 1.5.4 */ private function register_stored_original( $attachment_id, $original_path ) { $attachment_id = (int) $attachment_id; if ( $attachment_id <= 0 || empty( $original_path ) ) { return; } update_post_meta( $attachment_id, '_gatewell_original_file', $original_path ); } /** * Delete the stored original file when its attachment is deleted. * * Hooked to `delete_attachment`. Silently does nothing when the attachment has no * stored original or when the file has already been removed. * * @param int $attachment_id Attachment ID being deleted. * @return void * @since 1.5.4 */ public function delete_stored_original( $attachment_id ) { $attachment_id = (int) $attachment_id; if ( $attachment_id <= 0 ) { return; } $original_path = get_post_meta( $attachment_id, '_gatewell_original_file', true ); if ( empty( $original_path ) || ! is_string( $original_path ) ) { return; } // Only ever delete inside the uploads directory. $upload_dir = wp_upload_dir(); $base_dir = isset( $upload_dir['basedir'] ) ? wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) ) : ''; $normalized = wp_normalize_path( $original_path ); if ( '' === $base_dir || strpos( $normalized, $base_dir ) !== 0 || strpos( $normalized, '../' ) !== false ) { delete_post_meta( $attachment_id, '_gatewell_original_file' ); return; } if ( file_exists( $normalized ) ) { wp_delete_file( $normalized ); } delete_post_meta( $attachment_id, '_gatewell_original_file' ); } /** * Block direct web access to a directory. * * Writes a .htaccess deny rule (Apache 2.2 and 2.4 syntax) and an index.php stub, * mirroring the pattern used by WooCommerce and Easy Digital Downloads for protected * upload folders. Existing files are left untouched. * * Static so every directory the plugin creates inside wp-content/uploads — * gatewell-originals/ and gatewell-temp/ alike — can be protected through the * same code path. * * @param string $directory Absolute path to the directory to protect. * @return void * @since 1.5.4 * @since 1.6.0 Made static and reused for the temp directory. */ public static function protect_directory( $directory ) { if ( empty( $directory ) || ! is_dir( $directory ) ) { return; } global $wp_filesystem; if ( empty( $wp_filesystem ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if ( empty( $wp_filesystem ) ) { return; } $directory = trailingslashit( $directory ); $files = array( '.htaccess' => "\n\tRequire all denied\n\n\n\tOrder deny,allow\n\tDeny from all\n\n", 'index.php' => " $contents ) { $path = $directory . $file; if ( $wp_filesystem->exists( $path ) ) { continue; } $wp_filesystem->put_contents( $path, $contents, FS_CHMOD_FILE ); } } /** * Send JSON error response. * * @param string $message Error message to send to user. * @since 1.0.0 */ private function send_error( $message ) { // Log error. // Send JSON error response. wp_send_json_error( array( 'message' => $message, ) ); } /** * Send JSON success response. * * @param array $data Success data to include in response. * @since 1.0.0 */ private function send_success( $data = array() ) { // Send JSON success response. wp_send_json_success( $data ); } /** * Calculate Jaccard similarity between two strings as a percentage. * * Normalizes both strings (lowercase, strip punctuation) then computes * word-level Jaccard: |intersection| / |union| * 100. * * @param string $a First string. * @param string $b Second string. * @return int Similarity percentage (0-100). */ private function jaccard_similarity( $a, $b ) { $normalize = function( $str ) { $str = gatewell_strtolower( $str ); $str = preg_replace( '/[^\p{L}\p{N}\s]/u', ' ', $str ); $str = preg_replace( '/\s+/', ' ', trim( $str ) ); return array_filter( explode( ' ', $str ) ); }; $words_a = $normalize( $a ); $words_b = $normalize( $b ); if ( empty( $words_a ) || empty( $words_b ) ) { return 0; } $intersection = count( array_intersect( $words_a, $words_b ) ); $union = count( array_unique( array_merge( $words_a, $words_b ) ) ); return $union > 0 ? (int) round( ( $intersection / $union ) * 100 ) : 0; } }