cleanup($existing_files_csv, $kept_files_csv); * $errors = $uploader->upload('product_image'); * if (empty($errors)) { * $file_string = $uploader->buildFileString($kept_files_csv); * // save $file_string to DB... * } else { * $uploader->rollbackUploads(); * } * * Security: * - Validates both file extension and MIME type via finfo (double-check). * - Sanitises original filename before use; replaces non-alphanumeric characters with '_'. * - Generates a unique filename with uniqid() to prevent overwrite attacks. * - Sets uploaded files to 0644 permissions (web-readable, not executable). * - Rejects any file exceeding the 5MB size limit. */ class FileUploader { private $target_dir; private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']; private $max_size = 5 * 1024 * 1024; // 5MB private $uploaded_files = []; // filenames of files successfully uploaded in this request private $deleted_files = []; // filenames of files deleted from disk in this request /** * Initialise the uploader for a specific directory. * * Creates the directory if it does not exist (recursive, 0755). * Throws if the directory cannot be created or is not writable. * * @param string $target_dir Absolute path to the upload directory (trailing slash optional). * @throws Exception If the directory cannot be created or is not writable. */ public function __construct($target_dir) { $this->target_dir = rtrim($target_dir, '/') . '/'; $this->ensureDirectory(); } // ───────────────────────────────────────────────────────────── // Public — cleanup // ───────────────────────────────────────────────────────────── /** * Delete files that the user removed in the UI. * * Compare the original comma-separated file list ($existing_csv) against * the files the user chose to keep ($keep_csv). Any file in $existing but * not in $keep is deleted from disk. * * Call this BEFORE upload() so that freed space is available and the * $deleted_files list is populated before any rollback is needed. * * @param string $existing_csv Comma-separated filenames previously stored in DB. * @param string $keep_csv Comma-separated filenames the user kept in the form. */ public function cleanup($existing_csv, $keep_csv) { if (empty($existing_csv)) return; $existing = array_filter(array_map('trim', explode(',', $existing_csv))); $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; foreach ($existing as $file) { if (!in_array($file, $keep)) { $path = $this->target_dir . $file; if (file_exists($path)) { unlink($path); $this->deleted_files[] = $file; } } } } // ───────────────────────────────────────────────────────────── // Public — upload // ───────────────────────────────────────────────────────────── /** * Validate and move uploaded files from $_FILES to the target directory. * * Handles both single-file and multi-file inputs transparently. * Each file is validated for: * - PHP upload error code (UPLOAD_ERR_OK) * - File size (≤ max_size, default 5MB) * - File extension (whitelist) * - MIME type via finfo (double-check against extension spoofing) * * Successfully uploaded files are tracked in $this->uploaded_files and * can be rolled back via rollbackUploads() if the DB write later fails. * * @param string $field_name The $_FILES key (HTML input name attribute). * @return array Array of human-readable error strings. Empty array = all files accepted. */ public function upload($field_name) { if (!isset($_FILES[$field_name])) return []; $errors = []; // Normalise single-file $_FILES entry to array structure $files = $_FILES[$field_name]; if (!is_array($files['name'])) { $files['name'] = [$files['name']]; $files['tmp_name'] = [$files['tmp_name']]; $files['error'] = [$files['error']]; $files['size'] = [$files['size']]; } foreach ($files['name'] as $key => $name) { $error_code = $files['error'][$key]; if ($error_code !== UPLOAD_ERR_OK) { $errors[] = "{$name}: " . $this->getUploadError($error_code); continue; } $tmp_name = $files['tmp_name'][$key]; $file_size = $files['size'][$key]; $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); // Size check if ($file_size > $this->max_size) { $errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit"; continue; } // Extension whitelist check if (!in_array($extension, $this->allowed_extensions)) { $errors[] = "{$name}: .{$extension} is not allowed"; continue; } // MIME type check via finfo (cannot be spoofed by renaming) $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmp_name); finfo_close($finfo); if (!in_array($mime, $this->allowed_mimes)) { $errors[] = "{$name}: content type ({$mime}) is not allowed"; continue; } // Sanitise original filename and generate a unique destination name $original = pathinfo($name, PATHINFO_FILENAME); $original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original); $file_id = $original . '_' . uniqid() . '.' . $extension; $destination = $this->target_dir . $file_id; if (move_uploaded_file($tmp_name, $destination)) { $this->uploaded_files[] = $file_id; chmod($destination, 0644); // web-readable, not executable } else { $errors[] = "{$name}: failed to save"; } } return $errors; } // ───────────────────────────────────────────────────────────── // Public — result helpers // ───────────────────────────────────────────────────────────── /** * Build the final comma-separated filename string for DB storage. * * Merges the files the user kept ($keep_csv) with any newly uploaded * files from this request, and returns them as a single CSV string. * Pass this return value to the DB column that stores file references. * * @param string $keep_csv Comma-separated filenames the user kept in the form. * @return string Final comma-separated file list ready for DB storage. */ public function buildFileString($keep_csv) { $keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; $final = array_merge($keep, $this->uploaded_files); return implode(",", array_filter($final)); } /** * Return the list of filenames successfully uploaded in this request. * * Useful when only a single image field is expected and you need to * retrieve the stored filename directly without building a CSV string. * * @return array Flat array of uploaded filenames (may be empty). */ public function getUploadedFiles() { return $this->uploaded_files; } /** * Delete all files uploaded in this request — call if the DB write fails. * * Ensures no orphaned files remain on disk when a transaction is rolled back. * Safe to call multiple times (idempotent if files are already gone). * After rollback, $this->uploaded_files is cleared. */ public function rollbackUploads() { foreach ($this->uploaded_files as $file) { $path = $this->target_dir . $file; if (file_exists($path)) { unlink($path); } } $this->uploaded_files = []; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── /** * Create the target directory if it does not exist, and verify it is writable. * * @throws Exception If mkdir fails or the directory is not writable. */ private function ensureDirectory() { if (!is_dir($this->target_dir)) { if (!mkdir($this->target_dir, 0755, true)) { throw new Exception("Failed to create directory: " . $this->target_dir); } } if (!is_writable($this->target_dir)) { throw new Exception("Target directory is not writable: " . $this->target_dir); } } /** * Convert a PHP upload error code into a human-readable message. * * Covers all UPLOAD_ERR_* constants defined by PHP. * Falls back to a generic message for unknown codes. * * @param int $code A UPLOAD_ERR_* constant value. * @return string Human-readable error description. */ private function getUploadError($code) { $messages = [ UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")", UPLOAD_ERR_FORM_SIZE => "exceeds form max size", UPLOAD_ERR_PARTIAL => "was only partially uploaded", UPLOAD_ERR_NO_FILE => "no file was sent", UPLOAD_ERR_NO_TMP_DIR => "server missing temp folder", UPLOAD_ERR_CANT_WRITE => "server failed to write to disk", UPLOAD_ERR_EXTENSION => "blocked by server extension", ]; return $messages[$code] ?? "unknown error (code {$code})"; } }