[ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', ], 'max_size' => 5242880, // 5MB 'convert_to_webp' => true, 'webp_quality' => 80, 'optimize_images' => true, 'create_thumbnails' => true, 'original_retention' => 2592000, // 30 days in seconds 'use_imagick' => null // Will be set in constructor ]; protected string $post_type; protected int $user_id; protected int $term_id; protected string $upload_dir; protected string $upload_url; protected object $cache; protected object $notifications; protected int $max_retries = 3; protected int $retry_delay = 300; // 5 minutes public function __construct($post_type, $user_id, $config = []) { $this->post_type = $post_type; $this->user_id = $user_id; $this->config = wp_parse_args($config, $this->config); // Check for Imagick availability $this->config['use_imagick'] = extension_loaded('imagick'); $this->setupUploadDirs(); // Schedule cleanup of original files // if (!wp_next_scheduled('jvb_cleanup_original_uploads')) { // wp_schedule_event(time(), 'daily', 'jvb_cleanup_original_uploads'); // } // add_action('jvb_cleanup_original_uploads', [$this, 'cleanupOriginalFiles']); // Track upload statistics add_action('jvb_upload_complete', [$this, 'trackUploadStats']); add_action('jvb_upload_failed', [$this, 'track_failed_upload']); } /** * @param array $file_data * * @return array * @throws Exception */ public function secureUploadedFile(array $file_data): array { // Create a persistent temporary directory if it doesn't exist $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$this->user_id}"; wp_mkdir_p($temp_dir); // Generate a unique filename for temporary storage $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']); $temp_path = "{$temp_dir}/{$temp_filename}"; // Move the uploaded file to our temporary storage if (!move_uploaded_file($file_data['tmp_name'], $temp_path)) { // Fallback to copy if needed if (!copy($file_data['tmp_name'], $temp_path)) { throw new Exception('Failed to store uploaded file'); } } // Return metadata about the stored file return [ 'temp_path' => $temp_path, 'original_name' => $file_data['name'], 'mime_type' => $file_data['type'], 'file_size' => $file_data['size'], 'stored_at' => current_time('mysql') ]; } /** * @param string $temp_path * @param string $post_type * @param int $post_id * @param int $term_id * @param string $original_filename * * @return array * @throws Exception */ public function processImageFromStorage(string $temp_path, string $post_type, int $post_id = 0, int $term_id = 0, string $original_filename = ''): array { if (!file_exists($temp_path)) { throw new Exception('Stored file no longer exists: ' . $temp_path); } $this->term_id = $term_id; $this->post_type = $post_type; // Get base upload directory based on content type $rel_path = $this->getUploadDirectory(); // Ensure the directory exists $full_upload_path = $this->upload_dir . '/' . $rel_path; if (!wp_mkdir_p($full_upload_path)) { throw new Exception("Failed to create upload directory: {$full_upload_path}"); } // Generate filename WITHOUT extension (generateFilename should not include extension) $base_filename = $this->generateFilename($original_filename, get_userdata($this->user_id)); // Process the image directly from our temporary storage return $this->processImage($temp_path, $rel_path, $base_filename, $post_id); } /** * @return void * @throws Exception */ protected function setupUploadDirs():void { $upload_info = wp_upload_dir(); if ($upload_info['error']) { throw new Exception($upload_info['error']); } $this->upload_dir = $upload_info['basedir']; $this->upload_url = $upload_info['baseurl']; } /** * @return string */ public function getUploadDirectory():string { // Default WordPress organization: year/month $default_path = date('Y/m'); /** * Filter the upload directory structure * * @param string $path The default upload path * @param string $post_type The post type being uploaded * @param int $user_id The user ID * @param int $term_id The term ID (if applicable) */ return apply_filters('jvb_upload_directory', $default_path, $this->post_type, $this->user_id, $this->term_id); } /** * Generate filename with extensible filtering * Default: generic SEO-friendly format * * @param string $original_name * @param object $user_data * @return string */ public function generateFilename(string $original_name, object $user_data): string { // Default generic filename: {post_type}-{user_id}-{counter} $filename = sprintf( '%s-%s', sanitize_title($this->post_type), $this->user_id ); /** * Filter the generated filename (without extension) * * @param string $filename The generated filename * @param string $original_name The original filename * @param object $user_data WordPress user data object * @param string $post_type The post type * @param int $user_id The user ID * @param int $term_id The term ID (if applicable) */ return apply_filters('jvb_upload_filename', $filename, $original_name, $user_data, $this->post_type, $this->user_id, $this->term_id).'-'.$this->getNextFileCounter(); } /** * @return string */ protected function getNextFileCounter(): string { // Get counter key for this post type $counter_key = 'upload_counter_' . str_replace(['/', '\\'], '_', $this->post_type); // Get current counter value, default to 0 $counter = (int)get_user_meta($this->user_id, $counter_key, true) ?: 0; // Increment counter $counter++; // Update counter in user meta update_user_meta($this->user_id, $counter_key, $counter); // Return formatted counter return sprintf('%08d', $counter); } /** * Generate alt text with filtering for customization * Default: basic or empty alt text * * @param string $file * @param object $user_data * @param int|null $post_id * @return string */ protected function generateAltText(string $file, object $user_data, int|null $post_id = null): string { // Default: basic alt text or empty $alt_text = ''; /** * Filter the generated alt text * * @param string $alt_text The generated alt text * @param string $file The file path * @param object $user_data WordPress user data object * @param int|null $post_id The post ID (if applicable) * @param string $post_type The post type * @param int $user_id The user ID * @param int $term_id The term ID (if applicable) */ return apply_filters('jvb_upload_alt_text', $alt_text, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id); } /** * Generate image title with filtering for customization * Default: WordPress default behavior (filename-based) * * @param string $file * @param object $user_data * @param int|null $post_id * @return string */ protected function generateImageTitle(string $file, object $user_data, int|null $post_id = null): string { // Default: Use filename without extension (WordPress default behavior) $title = pathinfo($file, PATHINFO_FILENAME); /** * Filter the generated image title * * @param string $title The generated title * @param string $file The file path * @param object $user_data WordPress user data object * @param int|null $post_id The post ID (if applicable) * @param string $post_type The post type * @param int $user_id The user ID * @param int $term_id The term ID (if applicable) */ return apply_filters('jvb_upload_image_title', $title, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id); } /** * @param array $file_data * @param array $options * * @return array|WP_Error */ public function handleContentUpload(array $file_data, array $options = []): array|WP_Error { try { if (!isset($file_data['tmp_name']) || !is_uploaded_file($file_data['tmp_name'])) { throw new Exception('No valid file uploaded.'); } $user_data = get_userdata($this->user_id); $post_id = $options['post_id'] ?? 0; $this->term_id = $options['term_id'] ?? 0; // Get base upload directory based on content type $base_dir = $this->getUploadDirectory(); $original_dir = 'originals'; $rel_path = $base_dir; $this->ensureUploadDirs($rel_path, $original_dir); $filename = $this->generateFilename($file_data['name'], $user_data); // Store original file $original_file = $this->storeOriginalFile( $file_data['tmp_name'], $base_dir, $original_dir, $filename ); // Process immediately return $this->processImage($original_file, $rel_path, $filename, $post_id); } catch (Exception $e) { return new WP_Error('upload_failed', $e->getMessage()); } } /** * @param string $rel_path * @param string $original_dir * * @return void * @throws Exception */ protected function ensureUploadDirs($rel_path, $original_dir): void { $dirs = [ "{$this->upload_dir}/{$rel_path}", "{$this->upload_dir}/{$rel_path}/{$original_dir}" ]; foreach ($dirs as $dir) { if (!wp_mkdir_p($dir)) { throw new Exception("Failed to create directory: {$dir}"); } } } /** * @param string $tmp_file * @param string $base_dir * @param string $original_dir * @param string $filename * @return string * @throws Exception */ protected function storeOriginalFile(string $tmp_file, string $base_dir, string $original_dir, string $filename): string { $ext = pathinfo($tmp_file, PATHINFO_EXTENSION); $original_path = "{$this->upload_dir}/{$base_dir}/{$original_dir}/{$filename}.{$ext}"; if (!move_uploaded_file($tmp_file, $original_path)) { throw new Exception('Failed to store original file'); } return $original_path; } /** * Process image with better error handling */ protected function processImage(string $original_file, string $rel_path, string $filename, int $post_id): array { // Validate the original file still exists if (!file_exists($original_file)) { throw new Exception('Original file no longer exists: ' . $original_file); } // Verify file type before processing $mime_type = mime_content_type($original_file); if (!in_array($mime_type, $this->config['allowed_types'])) { throw new Exception('Invalid file type detected during processing: ' . $mime_type); } // Ensure the upload directory exists $full_upload_dir = $this->upload_dir . '/' . $rel_path; if (!wp_mkdir_p($full_upload_dir)) { throw new Exception("Failed to create upload directory: {$full_upload_dir}"); } // Convert to WebP if enabled if ($this->config['convert_to_webp'] && $mime_type !== 'image/webp') { $final_path = "{$full_upload_dir}/{$filename}.webp"; if ($this->config['use_imagick']) { $this->convertWithImagick($original_file, $final_path); } else { $this->convertWithGd($original_file, $final_path); } } else { // Just copy the original with its extension $original_ext = pathinfo($original_file, PATHINFO_EXTENSION); $final_path = "{$full_upload_dir}/{$filename}.{$original_ext}"; if (!copy($original_file, $final_path)) { throw new Exception("Failed to copy file from {$original_file} to {$final_path}"); } } // Verify the final file was created if (!file_exists($final_path)) { throw new Exception("Final processed file was not created: {$final_path}"); } // Generate title text $title = $this->generateImageTitle( $final_path, get_userdata($this->user_id), $post_id ); // Create attachment with title $attachment_id = $this->createAttachment($final_path, $title, $post_id); // Generate thumbnails if ($this->config['create_thumbnails']) { $this->generateThumbnails($attachment_id); } // Update post attachments with new file info $this->updatePostAttachments($attachment_id, $final_path); return [ 'success' => true, 'attachment_id' => $attachment_id, 'url' => wp_get_attachment_url($attachment_id), 'file' => $final_path ]; } /** * @param string $source * @param string $destination * * @return void * @throws Exception */ protected function convertWithImagick(string $source, string $destination, string $toType = 'webp'): void { $allowed = ['webp', 'jpeg', 'jpg', 'png']; if (!in_array($toType, $allowed)) { return; } try { $image = new Imagick($source); $image->setImageFormat('webp'); $image->setImageCompressionQuality($this->config['webp_quality']); $image->writeImage($destination); $image->clear(); } catch (Exception $e) { throw new Exception('WebP conversion with Imagick failed: ' . $e->getMessage()); } } /** * Fixed convertWithGd method with better error handling */ protected function convertWithGd(string $source, string $destination, string $toType = 'webp'): void { $mime_type = mime_content_type($source); // Ensure destination directory exists $dest_dir = dirname($destination); if (!wp_mkdir_p($dest_dir)) { throw new Exception("Failed to create destination directory: {$dest_dir}"); } switch ($mime_type) { case 'image/webp': $image = imagecreatefromwebp($source); break; case 'image/jpeg': $image = imagecreatefromjpeg($source); break; case 'image/png': $image = imagecreatefrompng($source); if ($image !== false) { imagepalettetotruecolor($image); imagealphablending($image, true); imagesavealpha($image, true); } break; case 'image/gif': $image = imagecreatefromgif($source); if ($image !== false) { imagepalettetotruecolor($image); } break; default: throw new Exception('Unsupported image type for GD conversion: ' . $mime_type); } if ($image === false) { throw new Exception('Failed to create image resource from source file: ' . $source); } // Convert to WebP $result = imagewebp($image, $destination, $this->config['webp_quality']); // Clean up memory imagedestroy($image); if (!$result) { throw new Exception('WebP conversion with GD failed - imagewebp returned false'); } // Verify the file was actually created if (!file_exists($destination)) { throw new Exception('WebP file was not created despite imagewebp returning true'); } } /** * @return void */ public function cleanupOriginalFiles(): void { $cutoff = time() - $this->config['original_retention']; // Get upload directory and find original directories $pattern = "{$this->upload_dir}/**/originals"; $original_dirs = glob($pattern, GLOB_ONLYDIR); foreach ($original_dirs as $original_dir) { $files = glob("{$original_dir}/*"); foreach ($files as $file) { if (filemtime($file) < $cutoff) { unlink($file); } } } } /** * @param string|null $temp_dir * @return void */ protected function cleanupTempFiles(string|null $temp_dir = null): void { if (is_null($temp_dir)) { $temp_dir = $this->upload_dir . '/tmp'; } if (is_dir($temp_dir)) { $files = glob($temp_dir . '/*'); foreach ($files as $file) { if (is_file($file)) { @unlink($file); } } @rmdir($temp_dir); } } /** * @param int $retention_days * @return void */ public function cleanupUserFiles(int $retention_days = 30): void { $cutoff = time() - ($retention_days * DAY_IN_SECONDS); $user_dir = $this->getUploadDirectory(); if (!is_dir($user_dir)) { return; } $this->cleanupDirectory($user_dir, $cutoff); } /** * @param string $dir * @param int $cutoff * @return void */ protected function cleanupDirectory(string $dir, int $cutoff): void { $files = glob($dir . '/*'); foreach ($files as $file) { if (is_dir($file)) { $this->cleanupDirectory($file, $cutoff); } elseif (filemtime($file) < $cutoff) { @unlink($file); } } } /** * @param string $file * @param string $title * @param int $post_id * @return int|WP_Error * @throws Exception */ protected function createAttachment(string $file, string $title, int $post_id): int|WP_Error { $file_url = str_replace($this->upload_dir, $this->upload_url, $file); $attachment = [ 'post_mime_type' => mime_content_type($file), 'post_title' => $title, 'post_name' => sanitize_title($title), 'post_content' => '', 'post_status' => 'inherit', 'guid' => $file_url ]; $attach_id = wp_insert_attachment($attachment, $file, $post_id); if (is_wp_error($attach_id)) { throw new Exception($attach_id->get_error_message()); } // Generate and set alt text $alt_text = $this->generateAltText($file, get_userdata($this->user_id), $post_id); update_post_meta($attach_id, '_wp_attachment_image_alt', $alt_text); return $attach_id; } /** * Generate thumbnails using WordPress's built-in image size system * This will create all registered image sizes (thumbnail, medium, large, and any custom sizes) * Sites can register custom image sizes using add_image_size() * * @param int $attachment_id * @return void */ protected function generateThumbnails(int $attachment_id): void { require_once(ABSPATH . 'wp-admin/includes/image.php'); $metadata = wp_generate_attachment_metadata($attachment_id, get_attached_file($attachment_id)); wp_update_attachment_metadata($attachment_id, $metadata); } /** * @param $result * @return void */ protected function trackUploadStats($result) { $stats_key = "upload_stats_{$this->user_id}"; $stats = wp_cache_get($stats_key) ?: [ 'total_uploads' => 0, 'successful_uploads' => 0, 'failed_uploads' => 0, 'total_size' => 0, 'last_upload' => null ]; if ($result['success']) { $stats['successful_uploads']++; $stats['total_size'] += filesize($result['file']); } else { $stats['failed_uploads']++; } $stats['total_uploads']++; $stats['last_upload'] = current_time('mysql'); wp_cache_set($stats_key, $stats, '', DAY_IN_SECONDS); } /** * @param int $attachment_id * @param string $new_file_path * @return void */ protected function updatePostAttachments(int $attachment_id, string $new_file_path): void { // Update attachment post $file_url = str_replace($this->upload_dir, $this->upload_url, $new_file_path); $filename = basename($new_file_path); wp_update_post([ 'ID' => $attachment_id, 'guid' => $file_url, 'post_mime_type' => mime_content_type($new_file_path), 'post_title' => $filename ]); // Update attachment metadata update_post_meta($attachment_id, '_wp_attached_file', str_replace($this->upload_dir . '/', '', $new_file_path)); // Update attachment metadata including sizes $metadata = wp_get_attachment_metadata($attachment_id); if ($metadata) { $metadata['file'] = str_replace($this->upload_dir . '/', '', $new_file_path); // Update thumbnail paths if they exist if (!empty($metadata['sizes'])) { foreach ($metadata['sizes'] as $size => $info) { $old_file = $info['file']; $new_file = preg_replace( '/\.(jpe?g|png|gif)$/i', '.webp', $old_file ); $metadata['sizes'][$size]['file'] = $new_file; $metadata['sizes'][$size]['mime-type'] = 'image/webp'; } } wp_update_attachment_metadata($attachment_id, $metadata); } // If this is a profile/featured image, update those references $post_id = wp_get_post_parent_id($attachment_id); if ($post_id) { $featured_image_id = get_post_thumbnail_id($post_id); if ($featured_image_id === $attachment_id) { // Re-set the featured image to trigger any necessary updates set_post_thumbnail($post_id, $attachment_id); } } // Clear any caches clean_attachment_cache($attachment_id); clean_post_cache($post_id); } /** * Clean up empty temporary directories * * @param int $user_id * @return void */ public function cleanupEmptyTempDirs(int $user_id): void { $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$user_id}"; if (is_dir($temp_dir)) { // Check if directory is empty $files = scandir($temp_dir); $is_empty = (count($files) <= 2); // Only . and .. entries if ($is_empty) { // Try to remove the empty directory @rmdir($temp_dir); // Also check if parent temp directory is empty $parent_temp_dir = "{$this->upload_dir}/jvb_temp_uploads"; $parent_files = scandir($parent_temp_dir); $parent_is_empty = (count($parent_files) <= 2); // Only . and .. entries if ($parent_is_empty) { @rmdir($parent_temp_dir); } } } } }