| | |
| | | namespace JVBase\managers; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\meta\MetaManager; |
| | | use Exception; |
| | | use Imagick; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Handles file uploads for edmonton.ink dashboard |
| | | * Includes image processing, validation, optimization, and SEO-friendly naming |
| | | * Flexible file upload manager for images, videos, and documents |
| | | * Supports configurable directory structures and processing options |
| | | */ |
| | | class UploadManager |
| | | { |
| | | /** |
| | | * @var array Default configuration |
| | | */ |
| | | protected array $config = [ |
| | | 'allowed_types' => [ |
| | | '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 array $allowedTypes = [ |
| | | // Images |
| | | 'image/jpeg' => 'image', |
| | | 'image/png' => 'image', |
| | | 'image/gif' => 'image', |
| | | 'image/webp' => 'image', |
| | | // Videos |
| | | 'video/mp4' => 'video', |
| | | 'video/webm' => 'video', |
| | | 'video/ogg' => 'video', |
| | | 'video/ogv' => 'video', |
| | | 'video/quicktime' => 'video', |
| | | 'video/x-msvideo' => 'video', |
| | | // Documents |
| | | 'application/pdf' => 'document', |
| | | 'application/msword' => 'document', |
| | | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'document', |
| | | 'application/vnd.ms-excel' => 'document', |
| | | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'document', |
| | | 'text/plain' => 'document', |
| | | 'text/csv' => 'document', |
| | | 'application/rtf' => 'document' |
| | | ]; |
| | | |
| | | 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'); |
| | | protected array $supportedFormats = [ |
| | | 'webp' => 'image/webp', |
| | | 'jpeg' => 'image/jpeg', |
| | | 'jpg' => 'image/jpeg', |
| | | 'png' => 'image/png', |
| | | 'gif' => 'image/gif' |
| | | ]; |
| | | |
| | | $this->setupUploadDirs(); |
| | | protected array $defaultConfig = [ |
| | | 'allowed_types' => null, // null = all types allowed |
| | | 'max_size' => [ |
| | | 'image' => 5242880, // 5MB |
| | | 'video' => 104857600, // 100MB |
| | | 'document' => 10485760 // 10MB |
| | | ], |
| | | // Image settings |
| | | 'convert' => 'webp', |
| | | 'quality' => 80, |
| | | 'optimize_images' => true, |
| | | 'create_thumbnails' => true, |
| | | 'use_imagick' => null, |
| | | // Video settings |
| | | 'extract_video_thumbnail' => true, |
| | | 'video_thumbnail_time' => 0, |
| | | // Document settings |
| | | 'extract_document_preview' => false, |
| | | // Directory structure |
| | | 'directory_pattern' => '{user_id}/{post_type}', |
| | | // General |
| | | 'original_retention' => 2592000, // 30 days |
| | | ]; |
| | | |
| | | |
| | | // 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 |
| | | protected string $upload_dir; |
| | | protected string $upload_url; |
| | | public function __construct() |
| | | { |
| | | // 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); |
| | | $upload = wp_upload_dir(); |
| | | $this->upload_dir = $upload['basedir']; |
| | | $this->upload_url = $upload['baseurl']; |
| | | |
| | | // 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); |
| | | // Check for Imagick |
| | | $this->defaultConfig['use_imagick'] = extension_loaded('imagick'); |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | * Initial, basic processing of upload data for later processing through OperationQueue.php |
| | | * @param array $file_data |
| | | * @param array $context |
| | | * @return array |
| | | */ |
| | | 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 |
| | | public function secureUploadedFile(array $file_data, array $context): array |
| | | { |
| | | try { |
| | | if (!isset($file_data['tmp_name']) || !is_uploaded_file($file_data['tmp_name'])) { |
| | | throw new Exception('No valid file uploaded.'); |
| | | // Validate file upload |
| | | if (!isset($file_data['tmp_name']) || $file_data['error'] !== UPLOAD_ERR_OK) { |
| | | throw new Exception('Invalid file upload'); |
| | | } |
| | | |
| | | $user_data = get_userdata($this->user_id); |
| | | $post_id = $options['post_id'] ?? 0; |
| | | $this->term_id = $options['term_id'] ?? 0; |
| | | $mime_type = mime_content_type($file_data['tmp_name']); |
| | | $file_type = $this->allowedTypes[$mime_type] ?? 'unknown'; |
| | | if ($file_type === 'unknown') { |
| | | throw new Exception('Unsupported file type: ' . $mime_type); |
| | | } |
| | | |
| | | // Get base upload directory based on content type |
| | | $base_dir = $this->getUploadDirectory(); |
| | | $original_dir = 'originals'; |
| | | // Check allowed types if specified |
| | | if ($this->defaultConfig['allowed_types'] && !in_array($mime_type, $this->defaultConfig['allowed_types'])) { |
| | | throw new Exception('File type not allowed: ' . $mime_type); |
| | | } |
| | | |
| | | $rel_path = $base_dir; |
| | | $this->ensureUploadDirs($rel_path, $original_dir); |
| | | $temp_dir = $this->generateDirectory($file_type, "{user_id}/temp", $context); |
| | | |
| | | $filename = $this->generateFilename($file_data['name'], $user_data); |
| | | // Ensure directories exist |
| | | $this->ensureDirectories($temp_dir); |
| | | |
| | | // Store original file |
| | | $original_file = $this->storeOriginalFile( |
| | | $file_data['tmp_name'], |
| | | $base_dir, |
| | | $original_dir, |
| | | $filename |
| | | ); |
| | | // Generate a unique filename for temporary storage |
| | | $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']); |
| | | $temp_path = "{$this->upload_dir}/{$temp_dir}/{$temp_filename}"; |
| | | |
| | | // Process immediately |
| | | return $this->processImage($original_file, $rel_path, $filename, $post_id); |
| | | // 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_type' => $file_type, |
| | | 'file_size' => $file_data['size'], |
| | | 'stored_at' => current_time('mysql') |
| | | ]; |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log('Failed to store uploaded file: ' . print_r($e->getMessage(), true)); |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Process an uploaded file with full context |
| | | * |
| | | * @param string $temp_path |
| | | * @param array $context Upload context and configuration |
| | | * @return array|WP_Error Result with attachment_id, url, file path |
| | | */ |
| | | public function processUpload(string $temp_path, array $context): array|WP_Error |
| | | { |
| | | try { |
| | | // Validate temp file exists |
| | | if (!file_exists($temp_path)) { |
| | | throw new Exception('Temporary file not found: ' . $temp_path); |
| | | } |
| | | |
| | | // Validate MIME type |
| | | $mime_type = mime_content_type($temp_path); |
| | | if (!$this->validateMimeType($temp_path, $mime_type)) { |
| | | throw new Exception('Invalid or potentially dangerous file type'); |
| | | } |
| | | $file_type = $this->allowedTypes[$mime_type] ?? 'unknown'; |
| | | |
| | | if ($file_type === 'unknown') { |
| | | throw new Exception('Unsupported file type: ' . $mime_type); |
| | | } |
| | | |
| | | // Merge config |
| | | $config = array_merge($this->defaultConfig, $context['config'] ?? [], $context); |
| | | |
| | | // Extract context |
| | | $user_id = $context['user_id'] ?? get_current_user_id(); |
| | | $post_id = $context['post_id'] ?? 0; |
| | | |
| | | // Check allowed types if specified |
| | | if (!empty($config['allowed_types']) && !in_array($mime_type, $config['allowed_types'])) { |
| | | throw new Exception('File type not allowed: ' . $mime_type); |
| | | } |
| | | |
| | | // Validate file size |
| | | $this->validateFileSize($temp_path, $file_type, $config); |
| | | |
| | | // Generate directory structure for final storage |
| | | $directory = $this->generateDirectory($file_type, $config['directory_pattern'] ?? '{user_id}/{content}/{file_type}', $context); |
| | | |
| | | // Ensure directories exist |
| | | $this->ensureDirectories($directory); |
| | | |
| | | // Generate filename |
| | | $original_name = $context['original_name'] ?? basename($temp_path); |
| | | $filename = $this->generateFilename($original_name, $context); |
| | | |
| | | // Store original from temp |
| | | $original_path = $this->storeOriginalFromTemp($temp_path, $directory, $filename); |
| | | |
| | | // Process based on file type |
| | | return match($file_type) { |
| | | 'image' => $this->processImage($original_path, $directory, $filename, $post_id, $config, $context), |
| | | 'video' => $this->processVideo($original_path, $directory, $filename, $post_id, $config, $context), |
| | | 'document' => $this->processDocument($original_path, $directory, $filename, $post_id, $config, $context), |
| | | default => throw new Exception('Unknown file type') |
| | | }; |
| | | |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log( |
| | | '[UploadManager]:processUpload', |
| | | $e->getMessage(), |
| | | ['temp_path' => $temp_path, 'context' => $context] |
| | | ); |
| | | |
| | | 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 |
| | | /** |
| | | * Store original file from temp location (not from PHP upload) |
| | | */ |
| | | protected function storeOriginalFromTemp(string $temp_path, string $directory, string $filename): string |
| | | { |
| | | $ext = pathinfo($temp_path, PATHINFO_EXTENSION); |
| | | $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}"; |
| | | |
| | | // Copy from temp to final location |
| | | if (!copy($temp_path, $original_path)) { |
| | | throw new Exception('Failed to store original file'); |
| | | } |
| | | |
| | | return $original_path; |
| | | } |
| | | |
| | | /** |
| | | * Clean up empty temporary directories |
| | | */ |
| | | public function cleanupEmptyTempDirs(int $user_id): void |
| | | { |
| | | $temp_base = "{$this->upload_dir}/{$user_id}/temp"; |
| | | |
| | | if (!is_dir($temp_base)) { |
| | | return; |
| | | } |
| | | |
| | | // Get all subdirectories |
| | | $dirs = glob($temp_base . '/*', GLOB_ONLYDIR); |
| | | |
| | | foreach ($dirs as $dir) { |
| | | // Check if directory is empty |
| | | $files = scandir($dir); |
| | | $files = array_diff($files, ['.', '..']); |
| | | |
| | | if (empty($files)) { |
| | | @rmdir($dir); |
| | | } |
| | | } |
| | | |
| | | // Try to remove temp base if empty |
| | | $files = scandir($temp_base); |
| | | $files = array_diff($files, ['.', '..']); |
| | | if (empty($files)) { |
| | | @rmdir($temp_base); |
| | | } |
| | | } |
| | | /** |
| | | * Generate directory structure based on pattern |
| | | */ |
| | | protected function generateDirectory(string $file_type, string $pattern, array $context): string |
| | | { |
| | | $replacements = [ |
| | | '{user_id}' => $context['user_id'] ?? get_current_user_id(), |
| | | '{post_type}' => $context['content'] ?? '', |
| | | '{content}' => $context['content'] ?? '', |
| | | '{file_type}' => $file_type, |
| | | '{year}' => date('Y'), |
| | | '{month}' => date('m'), |
| | | ]; |
| | | |
| | | $directory = str_replace(array_keys($replacements), array_values($replacements), $pattern); |
| | | |
| | | // Allow filtering |
| | | return apply_filters('jvb_upload_directory', $directory, $file_type, $context); |
| | | } |
| | | |
| | | /** |
| | | * Ensure directory structure exists |
| | | */ |
| | | protected function ensureDirectories(string $rel_path): void |
| | | { |
| | | $dirs = [ |
| | | "{$this->upload_dir}/{$rel_path}", |
| | | "{$this->upload_dir}/{$rel_path}/{$original_dir}" |
| | | "{$this->upload_dir}/{$rel_path}/originals" |
| | | ]; |
| | | |
| | | foreach ($dirs as $dir) { |
| | |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * @param string $tmp_file |
| | | * @param string $base_dir |
| | | * @param string $original_dir |
| | | * @param string $filename |
| | | * @return string |
| | | * @throws Exception |
| | | * Store original file |
| | | */ |
| | | protected function storeOriginalFile(string $tmp_file, string $base_dir, string $original_dir, string $filename): string |
| | | protected function storeOriginal(string $tmp_file, string $directory, string $filename): string |
| | | { |
| | | $ext = pathinfo($tmp_file, PATHINFO_EXTENSION); |
| | | $original_path = "{$this->upload_dir}/{$base_dir}/{$original_dir}/{$filename}.{$ext}"; |
| | | $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}"; |
| | | |
| | | if (!move_uploaded_file($tmp_file, $original_path)) { |
| | | throw new Exception('Failed to store original file'); |
| | |
| | | } |
| | | |
| | | /** |
| | | * Process image with better error handling |
| | | * Validate file size based on type and config |
| | | */ |
| | | protected function processImage(string $original_file, string $rel_path, string $filename, int $post_id): array |
| | | protected function validateFileSize(string $file_path, string $file_type, array $config): void |
| | | { |
| | | // Validate the original file still exists |
| | | if (!file_exists($original_file)) { |
| | | throw new Exception('Original file no longer exists: ' . $original_file); |
| | | $size = filesize($file_path); |
| | | $max_size = $config['max_size'][$file_type] ?? $this->defaultConfig['max_size'][$file_type]; |
| | | |
| | | if ($size > $max_size) { |
| | | $max_mb = round($max_size / 1048576, 2); |
| | | $actual_mb = round($size / 1048576, 2); |
| | | throw new Exception("File too large: {$actual_mb}MB (max: {$max_mb}MB)"); |
| | | } |
| | | } |
| | | |
| | | protected function validateMimeType(string $file_path, string $declared_type): bool |
| | | { |
| | | // Get actual MIME type |
| | | $actual_type = mime_content_type($file_path); |
| | | |
| | | // Check if actual type is allowed |
| | | if (!array_key_exists($actual_type, $this->allowedTypes)) { |
| | | return false; |
| | | } |
| | | |
| | | // 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); |
| | | // For images, verify it's actually an image |
| | | if (str_starts_with($actual_type, 'image/')) { |
| | | $image_info = @getimagesize($file_path); |
| | | if ($image_info === false) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // 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}"); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Process image file |
| | | * @throws Exception |
| | | */ |
| | | protected function processImage(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array |
| | | { |
| | | $full_dir = "{$this->upload_dir}/{$directory}"; |
| | | $original_mime = mime_content_type($original_path); |
| | | |
| | | // Determine if conversion is needed |
| | | $should_convert = false; |
| | | $target_format = false; |
| | | |
| | | if ($config['convert'] && $config['convert'] !== false) { |
| | | $target_format = strtolower($config['convert']); |
| | | |
| | | // Validate format |
| | | if (!isset($this->supportedFormats[$target_format])) { |
| | | throw new Exception("Unsupported conversion format: {$target_format}"); |
| | | } |
| | | |
| | | // Check if conversion is needed |
| | | $target_mime = $this->supportedFormats[$target_format]; |
| | | $should_convert = ($original_mime !== $target_mime); |
| | | } |
| | | |
| | | // Convert to WebP if enabled |
| | | if ($this->config['convert_to_webp'] && $mime_type !== 'image/webp') { |
| | | $final_path = "{$full_upload_dir}/{$filename}.webp"; |
| | | // Convert or copy based on configuration |
| | | if ($should_convert) { |
| | | $final_path = "{$full_dir}/{$filename}.{$target_format}"; |
| | | |
| | | if ($this->config['use_imagick']) { |
| | | $this->convertWithImagick($original_file, $final_path); |
| | | } else { |
| | | $this->convertWithGd($original_file, $final_path); |
| | | try { |
| | | $this->convertImage( |
| | | $original_path, |
| | | $final_path, |
| | | $target_format, |
| | | $config['quality'], |
| | | $config['use_imagick'] |
| | | ); |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log('Image conversion failed: ' . print_r($e->getMessage(), true)); |
| | | // Fallback to original |
| | | $ext = pathinfo($original_path, PATHINFO_EXTENSION); |
| | | $final_path = "{$full_dir}/{$filename}.{$ext}"; |
| | | copy($original_path, $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}"); |
| | | } |
| | | // Keep original format |
| | | $ext = pathinfo($original_path, PATHINFO_EXTENSION); |
| | | $final_path = "{$full_dir}/{$filename}.{$ext}"; |
| | | copy($original_path, $final_path); |
| | | } |
| | | |
| | | // Verify the final file was created |
| | | if (!file_exists($final_path)) { |
| | | throw new Exception("Final processed file was not created: {$final_path}"); |
| | | // Create attachment |
| | | $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id); |
| | | |
| | | // Set alt text if provided |
| | | $alt_text = apply_filters('jvb_upload_alt_text', '', $context); |
| | | if ($alt_text !== '') { |
| | | update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text); |
| | | } |
| | | |
| | | // 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']) { |
| | | if ($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 |
| | | 'file' => $final_path, |
| | | 'type' => 'image', |
| | | 'format' => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION) |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * @param string $source |
| | | * @param string $destination |
| | | * |
| | | * @return void |
| | | * @throws Exception |
| | | */ |
| | | protected function convertWithImagick(string $source, string $destination, string $toType = 'webp'): void |
| | | /** |
| | | * Process video file |
| | | * @throws Exception |
| | | */ |
| | | protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array |
| | | { |
| | | $allowed = ['webp', 'jpeg', 'jpg', 'png']; |
| | | if (!in_array($toType, $allowed)) { |
| | | return; |
| | | } |
| | | $full_dir = "{$this->upload_dir}/{$directory}"; |
| | | $ext = pathinfo($original_path, PATHINFO_EXTENSION); |
| | | $final_path = "{$full_dir}/{$filename}.{$ext}"; |
| | | |
| | | // Copy video to final location |
| | | copy($original_path, $final_path); |
| | | |
| | | // Create attachment |
| | | try { |
| | | $image = new Imagick($source); |
| | | $image->setImageFormat('webp'); |
| | | $image->setImageCompressionQuality($this->config['webp_quality']); |
| | | $image->writeImage($destination); |
| | | $image->clear(); |
| | | $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id); |
| | | } catch (Exception $e) { |
| | | throw new Exception('WebP conversion with Imagick failed: ' . $e->getMessage()); |
| | | JVB()->error()->log('Attachment creation failed: '.print_r($e->getMessage(), true)); |
| | | return ['success' => false]; |
| | | } |
| | | |
| | | |
| | | // Extract thumbnail if enabled |
| | | if ($config['extract_video_thumbnail']) { |
| | | $this->extractVideoThumbnail($attachment_id, $final_path, $config['video_thumbnail_time']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'attachment_id' => $attachment_id, |
| | | 'url' => wp_get_attachment_url($attachment_id), |
| | | 'file' => $final_path, |
| | | 'type' => 'video' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process document file |
| | | */ |
| | | protected function processDocument(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array |
| | | { |
| | | $full_dir = "{$this->upload_dir}/{$directory}"; |
| | | $ext = pathinfo($original_path, PATHINFO_EXTENSION); |
| | | $final_path = "{$full_dir}/{$filename}.{$ext}"; |
| | | |
| | | // Copy document to final location |
| | | copy($original_path, $final_path); |
| | | |
| | | // Create attachment |
| | | try { |
| | | $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id); |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log('Conversion failed: '.print_r($e->getMessage(), true)); |
| | | return ['success' => false]; |
| | | } |
| | | |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'attachment_id' => $attachment_id, |
| | | 'url' => wp_get_attachment_url($attachment_id), |
| | | 'file' => $final_path, |
| | | 'type' => 'document' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Generate SEO-friendly filename |
| | | */ |
| | | protected function generateFilename(string $original_name, array $context): string |
| | | { |
| | | $name_parts = pathinfo($original_name); |
| | | $base_name = sanitize_title($name_parts['filename']); |
| | | $user_data = array_key_exists('user_id', $context) ? get_userdata((int)$context['user_id']) : get_current_user(); |
| | | $username = $user_data ? sanitize_title($user_data->display_name) : 'user'; |
| | | $timestamp = time(); |
| | | |
| | | return apply_filters( |
| | | 'jvb_upload_filename', |
| | | "{$base_name}-{$timestamp}", |
| | | $context |
| | | ); |
| | | } |
| | | |
| | | protected function sanitizeFileName(string $filename): string |
| | | { |
| | | // Remove path info |
| | | $filename = basename($filename); |
| | | |
| | | // Remove special characters |
| | | $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename); |
| | | |
| | | // Remove multiple underscores |
| | | $filename = preg_replace('/_+/', '_', $filename); |
| | | |
| | | // Ensure it's not too long (255 char limit in most filesystems) |
| | | if (strlen($filename) > 200) { |
| | | $ext = pathinfo($filename, PATHINFO_EXTENSION); |
| | | $name = pathinfo($filename, PATHINFO_FILENAME); |
| | | $name = substr($name, 0, 200 - strlen($ext) - 1); |
| | | $filename = $name . '.' . $ext; |
| | | } |
| | | |
| | | return $filename; |
| | | } |
| | | |
| | | /** |
| | | * Create WordPress attachment |
| | | */ |
| | | protected function createAttachment(string $file_path, string $title, array $context, int $post_id = 0): int |
| | | { |
| | | $file_url = str_replace($this->upload_dir, $this->upload_url, $file_path); |
| | | $mime_type = mime_content_type($file_path); |
| | | |
| | | $title = apply_filters('jvb_upload_title', $title, $file_path, $context); |
| | | |
| | | $attachment_id = wp_insert_attachment([ |
| | | 'guid' => $file_url, |
| | | 'post_mime_type' => $mime_type, |
| | | 'post_title' => $title, |
| | | 'post_status' => 'inherit' |
| | | ], $file_path, $post_id); |
| | | |
| | | if (is_wp_error($attachment_id)) { |
| | | throw new Exception('Failed to create attachment'); |
| | | } |
| | | |
| | | require_once(ABSPATH . 'wp-admin/includes/image.php'); |
| | | $metadata = wp_generate_attachment_metadata($attachment_id, $file_path); |
| | | wp_update_attachment_metadata($attachment_id, $metadata); |
| | | |
| | | return $attachment_id; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Convert an existing attachment to a different format |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @param string $format Target format: 'webp', 'jpeg', 'png', 'gif' |
| | | * @param int $quality Conversion quality (1-100) |
| | | * @param bool $replace Replace original file (default: true) |
| | | * @return array|WP_Error Result with new attachment details |
| | | */ |
| | | public function convertImageTo(int $attachment_id, string $format, int $quality = 80, bool $replace = true): array|WP_Error |
| | | { |
| | | try { |
| | | // Validate format |
| | | $format = strtolower($format); |
| | | if (!isset($this->supportedFormats[$format])) { |
| | | throw new Exception("Unsupported format: {$format}"); |
| | | } |
| | | |
| | | // Get original file |
| | | $original_path = get_attached_file($attachment_id); |
| | | if (!$original_path || !file_exists($original_path)) { |
| | | throw new Exception('Original file not found'); |
| | | } |
| | | |
| | | // Check if it's an image |
| | | $mime_type = get_post_mime_type($attachment_id); |
| | | if (!str_starts_with($mime_type, 'image/')) { |
| | | throw new Exception('Attachment is not an image'); |
| | | } |
| | | |
| | | // Don't convert if already in target format |
| | | $current_mime = $this->supportedFormats[$format]; |
| | | if ($mime_type === $current_mime) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Already in target format', |
| | | 'attachment_id' => $attachment_id, |
| | | 'url' => wp_get_attachment_url($attachment_id) |
| | | ]; |
| | | } |
| | | |
| | | // Generate new filename |
| | | $path_info = pathinfo($original_path); |
| | | $new_filename = $path_info['filename'] . '.' . $format; |
| | | $new_path = $path_info['dirname'] . '/' . $new_filename; |
| | | |
| | | // Perform conversion |
| | | $use_imagick = $this->defaultConfig['use_imagick']; |
| | | $this->convertImage($original_path, $new_path, $format, $quality, $use_imagick); |
| | | |
| | | if ($replace) { |
| | | // Replace the original file |
| | | $this->replaceAttachmentFile($attachment_id, $new_path, $current_mime); |
| | | $result_attachment_id = $attachment_id; |
| | | } else { |
| | | // Create new attachment |
| | | $post_id = wp_get_post_parent_id($attachment_id); |
| | | $result_attachment_id = $this->createAttachment( |
| | | $new_path, |
| | | get_the_title($attachment_id), |
| | | ['user_id' => get_post_field('post_author', $attachment_id)], |
| | | $post_id |
| | | ); |
| | | } |
| | | |
| | | // Regenerate thumbnails |
| | | $this->generateThumbnails($result_attachment_id); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'attachment_id' => $result_attachment_id, |
| | | 'url' => wp_get_attachment_url($result_attachment_id), |
| | | 'format' => $format, |
| | | 'file' => $new_path |
| | | ]; |
| | | |
| | | } catch (Exception $e) { |
| | | return new WP_Error('conversion_failed', $e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Fixed convertWithGd method with better error handling |
| | | */ |
| | | protected function convertWithGd(string $source, string $destination, string $toType = 'webp'): void |
| | | /** |
| | | * Generic image conversion method |
| | | */ |
| | | protected function convertImage(string $source, string $destination, string $format, int $quality, bool $use_imagick): void |
| | | { |
| | | if ($use_imagick) { |
| | | $this->convertWithImagick($source, $destination, $format, $quality); |
| | | } else { |
| | | $this->convertWithGd($source, $destination, $format, $quality); |
| | | } |
| | | } |
| | | /** |
| | | * Convert image with Imagick (supports multiple formats) |
| | | */ |
| | | protected function convertWithImagick(string $source, string $destination, string $format, int $quality): void |
| | | { |
| | | try { |
| | | $image = new Imagick($source); |
| | | |
| | | // Set format |
| | | $image->setImageFormat($format); |
| | | |
| | | // Set quality |
| | | $image->setImageCompressionQuality($quality); |
| | | |
| | | // Format-specific optimizations |
| | | if ($format === 'webp') { |
| | | $image->setOption('webp:method', '6'); // Better compression |
| | | } elseif ($format === 'jpeg' || $format === 'jpg') { |
| | | $image->setImageCompression(Imagick::COMPRESSION_JPEG); |
| | | } elseif ($format === 'png') { |
| | | $image->setImageCompression(Imagick::COMPRESSION_ZIP); |
| | | } |
| | | |
| | | $image->writeImage($destination); |
| | | $image->clear(); |
| | | $image->destroy(); |
| | | } catch (Exception $e) { |
| | | throw new Exception("Imagick conversion to {$format} failed: " . $e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Convert image with GD (supports multiple formats) |
| | | */ |
| | | protected function convertWithGd(string $source, string $destination, string $format, int $quality): 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}"); |
| | | // Create image resource from source |
| | | $image = match($mime_type) { |
| | | 'image/jpeg' => imagecreatefromjpeg($source), |
| | | 'image/png' => imagecreatefrompng($source), |
| | | 'image/gif' => imagecreatefromgif($source), |
| | | 'image/webp' => imagecreatefromwebp($source), |
| | | default => throw new Exception('Unsupported source image type for GD conversion') |
| | | }; |
| | | |
| | | if (!$image) { |
| | | throw new Exception('Failed to create image resource'); |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | // Convert to target format |
| | | $result = match($format) { |
| | | 'webp' => imagewebp($image, $destination, $quality), |
| | | 'jpeg', 'jpg' => imagejpeg($image, $destination, $quality), |
| | | 'png' => imagepng($image, $destination, $this->qualityToPngCompression($quality)), |
| | | 'gif' => imagegif($image, $destination), |
| | | default => throw new Exception("Unsupported target format for GD: {$format}") |
| | | }; |
| | | |
| | | 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'); |
| | | throw new Exception("GD conversion to {$format} failed"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * @return void |
| | | * Convert quality (1-100) to PNG compression level (0-9) |
| | | */ |
| | | public function cleanupOriginalFiles(): void |
| | | protected function qualityToPngCompression(int $quality): int |
| | | { |
| | | $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); |
| | | } |
| | | } |
| | | } |
| | | // PNG compression is inverted: 0 = no compression, 9 = max compression |
| | | // Quality: 100 = best quality, 1 = worst quality |
| | | // So: quality 100 -> compression 0, quality 1 -> compression 9 |
| | | return (int) round((100 - $quality) / 11.11); |
| | | } |
| | | |
| | | /** |
| | | * @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 |
| | | * Generate image thumbnails |
| | | */ |
| | | protected function generateThumbnails(int $attachment_id): void |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * @param $result |
| | | * @return void |
| | | * Extract video thumbnail (placeholder - requires ffmpeg) |
| | | */ |
| | | protected function trackUploadStats($result) |
| | | protected function extractVideoThumbnail(int $attachment_id, string $video_path, int $time): void |
| | | { |
| | | $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); |
| | | // This would require ffmpeg or similar |
| | | // Placeholder for future implementation |
| | | apply_filters('jvb_extract_video_thumbnail', null, $attachment_id, $video_path, $time); |
| | | } |
| | | |
| | | /** |
| | | * @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 |
| | | * Phase 1: Validate and process file on disk (no DB writes) |
| | | * Safe to run outside transactions — only does file I/O |
| | | * |
| | | * @param int $user_id |
| | | * @return void |
| | | * @return array Prepared file info for registerFile() |
| | | */ |
| | | public function cleanupEmptyTempDirs(int $user_id): void |
| | | public function prepareFile(string $temp_path, array $context): array |
| | | { |
| | | $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$user_id}"; |
| | | if (!file_exists($temp_path)) { |
| | | throw new Exception('Temporary file not found: ' . $temp_path); |
| | | } |
| | | |
| | | if (is_dir($temp_dir)) { |
| | | // Check if directory is empty |
| | | $files = scandir($temp_dir); |
| | | $is_empty = (count($files) <= 2); // Only . and .. entries |
| | | $mime_type = mime_content_type($temp_path); |
| | | if (!$this->validateMimeType($temp_path, $mime_type)) { |
| | | throw new Exception('Invalid or potentially dangerous file type'); |
| | | } |
| | | |
| | | if ($is_empty) { |
| | | // Try to remove the empty directory |
| | | @rmdir($temp_dir); |
| | | $file_type = $this->allowedTypes[$mime_type] ?? 'unknown'; |
| | | if ($file_type === 'unknown') { |
| | | throw new Exception('Unsupported file type: ' . $mime_type); |
| | | } |
| | | |
| | | // 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 |
| | | $config = array_merge($this->defaultConfig, $context['config'] ?? [], $context); |
| | | $post_id = $context['post_id'] ?? 0; |
| | | |
| | | if ($parent_is_empty) { |
| | | @rmdir($parent_temp_dir); |
| | | } |
| | | if (!empty($config['allowed_types']) && !in_array($mime_type, $config['allowed_types'])) { |
| | | throw new Exception('File type not allowed: ' . $mime_type); |
| | | } |
| | | |
| | | $this->validateFileSize($temp_path, $file_type, $config); |
| | | |
| | | $directory = $this->generateDirectory( |
| | | $file_type, |
| | | $config['directory_pattern'] ?? '{user_id}/{content}/{file_type}', |
| | | $context |
| | | ); |
| | | $this->ensureDirectories($directory); |
| | | |
| | | $original_name = $context['original_name'] ?? basename($temp_path); |
| | | $filename = $this->generateFilename($original_name, $context); |
| | | $original_path = $this->storeOriginalFromTemp($temp_path, $directory, $filename); |
| | | |
| | | // File-type-specific I/O (conversion, copying) — no DB |
| | | $final_path = match ($file_type) { |
| | | 'image' => $this->prepareImage($original_path, $directory, $filename, $config), |
| | | 'video' => $this->prepareMedia($original_path, $directory, $filename), |
| | | 'document' => $this->prepareMedia($original_path, $directory, $filename), |
| | | default => throw new Exception('Unknown file type'), |
| | | }; |
| | | |
| | | return [ |
| | | 'final_path' => $final_path, |
| | | 'file_type' => $file_type, |
| | | 'filename' => $filename, |
| | | 'post_id' => $post_id, |
| | | 'config' => $config, |
| | | 'context' => $context, |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Phase 2: Register processed file in WordPress (DB writes only) |
| | | * Quick operation — individual WP functions handle their own queries |
| | | * |
| | | * @param array $prepared Output from prepareFile() |
| | | * @return array Standard upload result |
| | | */ |
| | | public function registerFile(array $prepared): array |
| | | { |
| | | $attachment_id = $this->createAttachment( |
| | | $prepared['final_path'], |
| | | $prepared['filename'], |
| | | $prepared['context'], |
| | | $prepared['post_id'] |
| | | ); |
| | | |
| | | // Type-specific post-registration |
| | | if ($prepared['file_type'] === 'image') { |
| | | $alt_text = apply_filters('jvb_upload_alt_text', '', $prepared['context']); |
| | | if ($alt_text !== '') { |
| | | update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text); |
| | | } |
| | | } |
| | | |
| | | if ($prepared['file_type'] === 'video' && ($prepared['config']['extract_video_thumbnail'] ?? false)) { |
| | | $this->extractVideoThumbnail( |
| | | $attachment_id, |
| | | $prepared['final_path'], |
| | | $prepared['config']['video_thumbnail_time'] ?? 0 |
| | | ); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'attachment_id' => $attachment_id, |
| | | 'url' => wp_get_attachment_url($attachment_id), |
| | | 'file' => $prepared['final_path'], |
| | | 'type' => $prepared['file_type'], |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Image file I/O: convert or copy to final location |
| | | */ |
| | | protected function prepareImage(string $original_path, string $directory, string $filename, array $config): string |
| | | { |
| | | $full_dir = "{$this->upload_dir}/{$directory}"; |
| | | $original_mime = mime_content_type($original_path); |
| | | |
| | | $should_convert = false; |
| | | $target_format = false; |
| | | |
| | | if ($config['convert'] && $config['convert'] !== false) { |
| | | $target_format = strtolower($config['convert']); |
| | | if (!isset($this->supportedFormats[$target_format])) { |
| | | throw new Exception("Unsupported conversion format: {$target_format}"); |
| | | } |
| | | $should_convert = ($original_mime !== $this->supportedFormats[$target_format]); |
| | | } |
| | | |
| | | if ($should_convert) { |
| | | $final_path = "{$full_dir}/{$filename}.{$target_format}"; |
| | | try { |
| | | $this->convertImage($original_path, $final_path, $target_format, $config['quality'], $config['use_imagick']); |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log('Image conversion failed: ' . $e->getMessage()); |
| | | $ext = pathinfo($original_path, PATHINFO_EXTENSION); |
| | | $final_path = "{$full_dir}/{$filename}.{$ext}"; |
| | | copy($original_path, $final_path); |
| | | } |
| | | } else { |
| | | $ext = pathinfo($original_path, PATHINFO_EXTENSION); |
| | | $final_path = "{$full_dir}/{$filename}.{$ext}"; |
| | | copy($original_path, $final_path); |
| | | } |
| | | |
| | | return $final_path; |
| | | } |
| | | |
| | | /** |
| | | * Video/document file I/O: copy to final location |
| | | */ |
| | | protected function prepareMedia(string $original_path, string $directory, string $filename): string |
| | | { |
| | | $full_dir = "{$this->upload_dir}/{$directory}"; |
| | | $ext = pathinfo($original_path, PATHINFO_EXTENSION); |
| | | $final_path = "{$full_dir}/{$filename}.{$ext}"; |
| | | |
| | | copy($original_path, $final_path); |
| | | |
| | | return $final_path; |
| | | } |
| | | } |