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