'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 ]; protected string $upload_dir; protected string $upload_url; public function __construct() { $upload = wp_upload_dir(); $this->upload_dir = $upload['basedir']; $this->upload_url = $upload['baseurl']; // Check for Imagick $this->defaultConfig['use_imagick'] = extension_loaded('imagick'); } /** * 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 { // Validate file upload if (!isset($file_data['tmp_name']) || $file_data['error'] !== UPLOAD_ERR_OK) { throw new Exception('Invalid file upload'); } $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); } // 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); } $temp_dir = $this->generateDirectory($file_type, "{user_id}/temp", $context); // Ensure directories exist $this->ensureDirectories($temp_dir); // 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) { 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()); } } /** * 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}/originals" ]; foreach ($dirs as $dir) { if (!wp_mkdir_p($dir)) { throw new Exception("Failed to create directory: {$dir}"); } } } /** * Store original file */ protected function storeOriginal(string $tmp_file, string $directory, string $filename): string { $ext = pathinfo($tmp_file, PATHINFO_EXTENSION); $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'); } return $original_path; } /** * Validate file size based on type and config */ protected function validateFileSize(string $file_path, string $file_type, array $config): void { $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; } // 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 $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 or copy based on configuration 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: ' . 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 if ($config['create_thumbnails']) { $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) ]; } /** * Process video file * @throws Exception */ protected function processVideo(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 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) { 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()); } } /** * 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); // 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); } /** * Phase 1: Validate and process file on disk (no DB writes) * Safe to run outside transactions — only does file I/O * * @return array Prepared file info for registerFile() */ public function prepareFile(string $temp_path, array $context): array { if (!file_exists($temp_path)) { throw new Exception('Temporary file not found: ' . $temp_path); } $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); } $config = array_merge($this->defaultConfig, $context['config'] ?? [], $context); $post_id = $context['post_id'] ?? 0; 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; } }