Upload, ensure files are stored in appropriate directory, usually {root}/tmp * -> Conversion is queued with Queue, as with the /tmp cleanup * * Proposed directory structure: * Site::has('membership') || Site::has('is_directory') * -> defaults to {user_id}/{post_type}/file * Default: * -> defaults to {post_type}/file * Drawback: if a post is uploaded to one post type, then used in another, the organization is slightly flawed. * * Also, we wanted to ensure images were converted to webp, for speed. * -> The front-end javascript attempts to do this in browser * -> If this couldn't be handled, it is queued for server processing * Of course, some integrations (even google, sillies) only accept jpeg/png, so in some cases we need a jpeg fallback * * */ class UploadManager { use Instance; 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 array $supportedFormats = [ 'webp' => 'image/webp', 'jpeg' => 'image/jpeg', 'jpg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif' ]; 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 ]; /****************************************************************** * UPLOAD SETTINGS ******************************************************************/ /** * @var array|int[] Max size of file types */ protected array $maxSize = [ 'image' => 5242880, // 5MB 'video' => 104857600, // 100MB 'document' => 15728640 // 15MB ]; //Image Specific /** * @var bool Whether to convert the image */ protected bool $convertImage = true; /** * @var string Default image conversion */ protected string $convertImageTo = 'webp'; /** * @var int Default image quality */ protected int $convertQuality = 80; /** * @var bool Whether to optimize images */ protected bool $optimizeImages = true; /** * @var bool Whether to create image thumbnails */ protected bool $createThumbnails = true; //Video specific /** * @var bool Whether to automatically extract a thumbnail from a video */ protected bool $extractVideoThumbnail = true; /** * @var int Time, in microseconds, to grab thumbnail from */ protected int $videoThumbnailPosition = 0; //Document specific /** * @var bool Whether to extract thumbnail from document. */ protected bool $extractDocumentThumbnail = false; /** * @var int How long to keep originals (default 3 months) */ protected int $keep_originals; /****************************************************************** * UPLOAD DIRECTORIES ******************************************************************/ /** * @var string WordPress' base upload directory */ protected string $upload_dir; /** * @var string WordPress' base upload directory url */ protected string $upload_url; protected string $dir_pattern; /***************************************************************** * FEATURE FLAG *****************************************************************/ protected bool $has_imagick = false; private function __construct() { $upload = wp_upload_dir(); $this->upload_dir = $upload['basedir']; $this->upload_url = $upload['baseurl']; if (Site::has('membership') || Site::has('is_directory')) { $this->dir_pattern = '{user_id}/{post_type}'; } else { $this->dir_pattern = '{post_type}'; } $this->has_imagick = extension_loaded('imagick'); $this->keep_originals = MONTH_IN_SECONDS * 3; add_action('init', [$this, 'registerUploadExecutors'], 5); } /** * Register upload operation types with the queue's TypeRegistry */ public function registerUploadExecutors(): void { $registry = Queue::getInstance()->registry(); $executor = new UploadExecutor(); $merger = new UploadMerger('secured_files'); // Image uploads - chunked at 5 files $registry->register('image_upload', new TypeConfig( mergeable: $merger, executor: $executor, chunkKey: 'secured_files', chunkSize: 3 )); // Video uploads - one at a time (heavy processing) $registry->register('video_upload', new TypeConfig( mergeable: $merger, executor: $executor, chunkKey: 'secured_files', chunkSize: 1 )); // Document uploads - chunked at 10 $registry->register('document_upload', new TypeConfig( mergeable: $merger, executor: $executor, chunkKey: 'secured_files', chunkSize: 5 )); // Metadata updates $registry->register('update_image_meta', new TypeConfig( executor: $executor )); // Cleanup - chunked at 5 $registry->register('temporary_cleanup', new TypeConfig( executor: $executor, chunkKey: 'files', chunkSize: 5 )); // Attach to content (depends on upload completing) $registry->register('attach_upload_to_content', new TypeConfig( executor: $executor )); // Process upload groups into posts $registry->register('process_upload_groups', new TypeConfig( executor: $executor, chunkKey: 'posts', chunkSize: 5 )); } /** * @param string|array $file_path * @param array $context * @return ?string * @throws Exception */ protected function testFile(string|array $file_path, array $context):?string { $file_type = null; if (is_array($file_path)) { if (!isset($file_path['tmp_name']) || $file_path['error'] !== UPLOAD_ERR_OK) { throw new Exception('Invalid file upload'); } $file_path = $file_path['tmp_name']; } // Validate file upload if (!isset($file_path)) { throw new Exception('Invalid file upload'); } $mime_type = mime_content_type($file_path); $file_type = $this->allowedTypes[$mime_type] ?? 'unknown'; if ($file_type === 'unknown') { throw new Exception('Unsupported file type: ' . $mime_type); } // Check allowed types if specified if (array_key_exists('allowed_types', $context) && !in_array($mime_type, $context['allowed_types'])) { throw new Exception('File type not allowed: ' . $mime_type); } return $file_type; } /** * Initial, basic processing of upload data for later processing through OperationQueue.php * @param array $file_data * @param array $context * @return array */ public function secureUploadedFile(array $file_data, array $context): array { try { $file_type = $this->testFile($file_data, $context); $context['pattern'] = $this->dir_pattern.'/tmp'; $temp_dir = $this->generateDirectory($file_type, $context); // 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}"; // 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) { Errors::getInstance()->log('UploadManager','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 { $file_type = $this->testFile($temp_path, $context); // Extract context $user_id = $context['user_id'] ?? get_current_user_id(); $post_id = $context['post_id'] ?? 0; // Validate file size $this->validateFileSize($temp_path, $file_type, $context); // Generate directory structure for final storage $directory = $this->generateDirectory($file_type, $context); // Generate filename $original_name = $context['original_name'] ?? basename($temp_path); $filename = $this->generateFilename($original_name, $context); // Process based on file type return match($file_type) { 'image' => $this->processImage($temp_path, $directory, $filename, $post_id, $context), 'video' => $this->processVideo($temp_path, $directory, $filename, $post_id, $context), 'document' => $this->processDocument($temp_path, $directory, $filename, $post_id, $context), default => throw new Exception('Unknown file type') }; } catch (Exception $e) { Errors::getInstance()->log( '[UploadManager]:processUpload', $e->getMessage(), ['temp_path' => $temp_path, 'context' => $context] ); return new WP_Error('upload_failed', $e->getMessage()); } } /** * 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 * @param string $file_type * @param array $config * @return string * @throws Exception */ public function generateDirectory(string $file_type, array $config): string { $pattern = array_key_exists('pattern', $config) && is_string($config['pattern']) ? $config['pattern'] : $this->dir_pattern; $pattern = explode('/', $pattern); $parts = []; foreach ($pattern as $part) { $newPart = match($part) { '{user_id}' => $context['user_id'] ?? null, '{post_type}', '{content}' => $context['content'] ?? null, '{tax}', '{taxonomy}' => $context['taxonomy'] ?? null, '{term}' => $context['term'] ?? null, '{file_type}' => $file_type, '{year}' => date('Y'), '{month}' => date('m'), '{day}' => date('d'), default => $part }; if (is_null($newPart)) { error_log('[Uploader] Pattern key was not provided for '.$part); } if (str_contains($newPart, '{')) { error_log('[Uploader] Unhandled pattern found: '. $newPart); } $newPart = $this->sanitizeFileName($newPart); $parts[] = $newPart; } //Remove any null values $parts = array_filter($parts); $directory = implode('/', $parts); // Allow filtering $directory = apply_filters('jvb_upload_directory', $directory, $file_type, $config); // Ensure directories exist $this->ensureDirectories($directory); return $directory; } /** * Ensure directory structure exists */ protected function ensureDirectories(string $rel_path): void { $dirs = [ "{$this->upload_dir}/{$rel_path}" ]; foreach ($dirs as $dir) { if (!wp_mkdir_p($dir)) { throw new Exception("Failed to create directory: {$dir}"); } } } /** * Validate file size based on type and config * @param string $file_path * @param string $file_type * @param array $config * @return void * @throws Exception */ protected function validateFileSize(string $file_path, string $file_type, array $config): void { $size = filesize($file_path); $max_size = $this->maxSize[$file_type]; if (array_key_exists('max_size', $config)) { if (is_int($config['max_size'])) { $max_size = $config['max_size']; } else if (is_array($config['max_size']) && array_key_exists($file_type, $config['max_size'])) { $max_size = $config['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; } // 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; } } return true; } /** * Process image file * @throws Exception */ protected function processImage(string $original_path, string $directory, string $filename, int $post_id, 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 ((array_key_exists('convert', $context) && $context['convert'] !== false) || $this->convertImage) { $target_format = empty($context['convertTo']??'') ? $this->convertImage : $context['convertTo']; if (!empty($target_format)) { // Validate format if (!isset($this->supportedFormats[$target_format])) { error_log('[Uploader] Unsupported conversion format: '.$target_format.'... will not convert'); } else { // Check if conversion is needed $target_mime = $this->supportedFormats[$target_format]; $should_convert = ($original_mime !== $target_mime); } } } // Convert or copy based on configuration if ($should_convert) { $final_path = "{$full_dir}/{$filename}.{$target_format}"; try { $this->convertImage( $original_path, $final_path, $target_format, array_key_exists('quality', $context) && is_int($context['quality']) && $context['quality'] <= 100 ? $context['quality'] : $this->convertQuality, ); } catch (Exception $e) { Errors::getInstance()->log('Uploader', '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 { // Keep original format $ext = pathinfo($original_path, PATHINFO_EXTENSION); $final_path = "{$full_dir}/{$filename}.{$ext}"; copy($original_path, $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 thumbnails $hasOption = array_key_exists('create_thumbnails', $context); if (($hasOption && $context['create_thumbnails']) || (!$hasOption && $this->createThumbnails)) { $this->generateThumbnails($attachment_id); } return [ 'success' => true, 'attachment_id' => $attachment_id, 'url' => wp_get_attachment_url($attachment_id), 'file' => $final_path, 'type' => 'image', 'format' => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION), 'context' => $context ]; } /** * Process video file * @throws Exception */ protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $context): array { $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 { $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id); } catch (Exception $e) { Errors::getInstance()->log('Uploader', 'Attachment creation failed: '.print_r($e->getMessage(), true)); return ['success' => false]; } // Extract thumbnail if enabled $hasOption = array_key_exists('extract_thumbnail', $context); if (($hasOption && $context['extract_thumbnail']) || (!$hasOption && $this->extractVideoThumbnail)) { $time = $context['thumbnail_time']??$this->videoThumbnailPosition; $this->extractVideoThumbnail($attachment_id, $final_path, $time); } return [ 'success' => true, 'attachment_id' => $attachment_id, 'url' => wp_get_attachment_url($attachment_id), 'file' => $final_path, 'type' => 'video', 'context' => $context ]; } /** * Process document file */ protected function processDocument(string $original_path, string $directory, string $filename, int $post_id, 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) { Errors::getInstance()->log('Uploader','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', 'context' => $context ]; } /** * Generate SEO-friendly filename */ public function generateFilename(string $original_name, array $context): string { $name_parts = pathinfo($original_name); $base_name = $this->sanitizeFileName($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 $this->sanitizeFileName(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 = false): 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 $this->convertImage($original_path, $new_path, $format, $quality); 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()); } } /** * Generic image conversion method */ /** * @param string $source * @param string $destination * @param string $format * @param int $quality * @return void * @throws Exception */ protected function convertImage(string $source, string $destination, string $format, int $quality): void { if ($this->has_imagick) { $this->convertWithImagick($source, $destination, $format, $quality); } else { $this->convertWithGd($source, $destination, $format, $quality); } } /** * Convert image with Imagick * @param string $source * @param string $destination * @param string $format * @param int $quality * @return void * @throws Exception */ 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(); } catch (Exception $e) { throw new Exception("Imagick conversion to {$format} failed: " . $e->getMessage()); } } /** * Convert image with GD * @param string $source * @param string $destination * @param string $format * @param int $quality * @return void * @throws Exception */ protected function convertWithGd(string $source, string $destination, string $format, int $quality): void { $mime_type = mime_content_type($source); // 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'); } // 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}") }; imagedestroy($image); if (!$result) { throw new Exception("GD conversion to {$format} failed"); } } /** * Convert quality (1-100) to PNG compression level (0-9) */ protected function qualityToPngCompression(int $quality): int { // 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); } /** * Generate image thumbnails */ 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); } /** * Extract video thumbnail (placeholder - requires ffmpeg) */ protected function extractVideoThumbnail(int $attachment_id, string $video_path, int $time): void { // This would require ffmpeg or similar // Placeholder for future implementation apply_filters('jvb_extract_video_thumbnail', null, $attachment_id, $video_path, $time); } }