| 1 |
<?php |
| 2 |
class AccuaForm_Element_File extends Element_File { |
| 3 |
protected $destPath; |
| 4 |
protected $alreadySubmittedText; |
| 5 |
// Modern default extensions: documents, images, archives |
| 6 |
protected $validExtensions = array( |
| 7 |
// Documents |
| 8 |
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'txt', 'rtf', 'csv', |
| 9 |
// Images |
| 10 |
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'heic', |
| 11 |
// Archives |
| 12 |
'zip', 'rar', '7z', 'tar', 'gz' |
| 13 |
); |
| 14 |
protected $maxSize; protected $limitsText; |
| 15 |
protected $errorsText; |
| 16 |
public function __construct($label, $name, ?array $properties = null) { |
| 17 |
$this->alreadySubmittedText = __( "File already submitted.", 'contact-forms' ); |
| 18 |
// translators: %size% and %extensions% are custom placeholders replaced via str_replace(), not sprintf(). |
| 19 |
$this->limitsText = __( 'Maximum filesize: %size%. Allowed extensions: %extensions%.', 'contact-forms' ); // phpcs:ignore WordPress.WP.I18n.UnorderedPlaceholdersText -- Custom placeholders used with str_replace(), not sprintf() |
| 20 |
$this->errorsText = array( |
| 21 |
'upload' => __( 'Upload failed, please retry. If the problem persists please contact us', 'contact-forms' ), |
| 22 |
'size' => __( 'Uploaded file is too big', 'contact-forms' ), |
| 23 |
'empty' => __( 'Uploaded file is empty', 'contact-forms' ), |
| 24 |
'name' => __( 'File name is not valid. Please rename it avoiding unusual characters', 'contact-forms' ), |
| 25 |
// translators: %extensions% is a custom placeholder replaced via str_replace(). |
| 26 |
'ext' => __( 'File extension not allowed. Allowed extensions are %extensions%', 'contact-forms' ), |
| 27 |
); |
| 28 |
|
| 29 |
// Use WordPress upload directory function instead of hardcoded path |
| 30 |
$wp_upload_dir = wp_upload_dir(); |
| 31 |
$accua_upload_dir = $wp_upload_dir['basedir'] . '/accua-forms'; |
| 32 |
|
| 33 |
// Create directory if it doesn't exist using WordPress function |
| 34 |
if (!file_exists($accua_upload_dir)) { |
| 35 |
wp_mkdir_p($accua_upload_dir); |
| 36 |
} |
| 37 |
|
| 38 |
// Use trailingslashit to ensure path ends with a slash |
| 39 |
$this->destPath = trailingslashit($accua_upload_dir); |
| 40 |
$this->maxSize = self::file_upload_max_size(); |
| 41 |
parent::__construct($label, $name, $properties); |
| 42 |
} |
| 43 |
|
| 44 |
public function __sleep() { |
| 45 |
return array("attributes", "label", "validation", 'alreadySubmittedText', 'validExtensions', 'maxSize', 'limitsText', 'errorsText', 'destPath'); |
| 46 |
} |
| 47 |
|
| 48 |
public function render() { |
| 49 |
if(empty($this->attributes["value"])){ |
| 50 |
// Build help text for limits |
| 51 |
$helpText = ''; |
| 52 |
if ($this->limitsText !== ''){ |
| 53 |
if ($this->validExtensions) { |
| 54 |
$validExtensions = implode(', ', $this->validExtensions); |
| 55 |
} else { |
| 56 |
$validExtensions = '*'; |
| 57 |
} |
| 58 |
$search = array('%size%', '%extensions%'); |
| 59 |
$replace = array(self::format_size($this->maxSize), $validExtensions); |
| 60 |
$helpText = str_replace($search, $replace, $this->limitsText); |
| 61 |
} |
| 62 |
|
| 63 |
// Build accept attribute for file extensions |
| 64 |
$acceptAttr = ''; |
| 65 |
if ($this->validExtensions && is_array($this->validExtensions)) { |
| 66 |
$acceptAttr = '.' . implode(',.', $this->validExtensions); |
| 67 |
} |
| 68 |
|
| 69 |
// Generate unique IDs for ARIA associations |
| 70 |
$inputId = esc_attr($this->getID() ?: 'accua-file-' . $this->getName()); |
| 71 |
$helpId = $inputId . '-help'; |
| 72 |
$announceId = $inputId . '-announce'; |
| 73 |
|
| 74 |
// Open wrapper with data attributes for JS |
| 75 |
echo '<div class="accua-file-upload-wrapper" data-max-size="' . esc_attr($this->maxSize) . '" data-accept="' . esc_attr($acceptAttr) . '">'; |
| 76 |
|
| 77 |
// Dropzone container |
| 78 |
echo '<div class="accua-file-dropzone" role="button" tabindex="0" '; |
| 79 |
echo 'aria-describedby="' . esc_attr($helpId) . '" '; |
| 80 |
echo 'aria-dropeffect="none">'; |
| 81 |
|
| 82 |
// Visual content for dropzone |
| 83 |
echo '<span class="accua-file-dropzone-text">'; |
| 84 |
echo '<span class="accua-file-dropzone-icon" aria-hidden="true">📁</span> '; |
| 85 |
echo esc_html__('Drag & drop file here or', 'contact-forms') . ' '; |
| 86 |
echo '<span class="accua-file-browse-btn">' . esc_html__('browse', 'contact-forms') . '</span>'; |
| 87 |
echo '</span>'; |
| 88 |
|
| 89 |
// Set accept attribute on native file input for browser file picker filtering |
| 90 |
if ($acceptAttr !== '') { |
| 91 |
$this->attributes['accept'] = $acceptAttr; |
| 92 |
} |
| 93 |
|
| 94 |
// Hidden native file input (for accessibility and form submission) |
| 95 |
parent::render(); |
| 96 |
|
| 97 |
echo '</div>'; // .accua-file-dropzone |
| 98 |
|
| 99 |
// Help text |
| 100 |
if ($helpText !== '') { |
| 101 |
echo '<p class="pfbc-help" id="' . esc_attr($helpId) . '">' . esc_html($helpText) . '</p>'; |
| 102 |
} |
| 103 |
|
| 104 |
// File list container (populated by JS) |
| 105 |
echo '<div class="accua-file-list" hidden aria-live="polite" aria-relevant="additions removals"></div>'; |
| 106 |
|
| 107 |
// Screen reader announcement region |
| 108 |
echo '<div class="accua-file-sr-announcement" id="' . esc_attr($announceId) . '" aria-live="polite" aria-atomic="true" class="screen-reader-text"></div>'; |
| 109 |
|
| 110 |
echo '</div>'; // .accua-file-upload-wrapper |
| 111 |
|
| 112 |
} else { |
| 113 |
echo esc_html($this->alreadySubmittedText); |
| 114 |
} |
| 115 |
} |
| 116 |
|
| 117 |
public function getAlreadySubmittedText() { |
| 118 |
return $this->alreadySubmittedText; |
| 119 |
} |
| 120 |
|
| 121 |
public function hasHelpText() { |
| 122 |
return !empty($this->limitsText); |
| 123 |
} |
| 124 |
|
| 125 |
public function appendToPostHTML($html) { |
| 126 |
$this->postHTML = ($this->postHTML ?? '') . $html; |
| 127 |
} |
| 128 |
|
| 129 |
public static function format_size($size) { |
| 130 |
if ($size >= 1073741824) { |
| 131 |
return round($size/1073741824, 2).' GB'; |
| 132 |
} else if ($size >= 1048576) { |
| 133 |
return round($size/1048576, 2).' MB'; |
| 134 |
} else if ($size >= 1024) { |
| 135 |
return round($size/1024, 2).' KB'; |
| 136 |
} else if ($size > 0) { |
| 137 |
return round($size).' B'; |
| 138 |
} else { |
| 139 |
return '-'; |
| 140 |
} |
| 141 |
} |
| 142 |
|
| 143 |
public static function parse_size($value) { |
| 144 |
$value = strtolower( trim( $value ) ); |
| 145 |
$bytes = (float) $value; |
| 146 |
|
| 147 |
if ( false !== strpos( $value, 'g' ) ) { |
| 148 |
$bytes *= 1073741824; // 1024 * 1024 * 1024 |
| 149 |
} elseif ( false !== strpos( $value, 'm' ) ) { |
| 150 |
$bytes *= 1048576; // 1024 * 1024 |
| 151 |
} elseif ( false !== strpos( $value, 'k' ) ) { |
| 152 |
$bytes *= 1024; |
| 153 |
} |
| 154 |
|
| 155 |
return min( $bytes, PHP_INT_MAX ); |
| 156 |
} |
| 157 |
|
| 158 |
public static function file_upload_max_size() { |
| 159 |
static $max_size = -1; |
| 160 |
|
| 161 |
if ($max_size < 0) { |
| 162 |
$max_size = wp_max_upload_size(); |
| 163 |
} |
| 164 |
return $max_size; |
| 165 |
} |
| 166 |
|
| 167 |
public function setMaxSize($size) { |
| 168 |
$phpMaxSize = self::file_upload_max_size(); |
| 169 |
$size = self::parse_size($size); |
| 170 |
return $this->maxSize = ($phpMaxSize < $size) ? $phpMaxSize : $size; |
| 171 |
} |
| 172 |
|
| 173 |
public function setErrorsText($errors){ |
| 174 |
if(is_array($errors)) { |
| 175 |
$this->errorsText = $errors + $this->errorsText; |
| 176 |
} |
| 177 |
} |
| 178 |
public function setDestPath($path) { |
| 179 |
// Check if path is absolute BEFORE normalizing (path_is_absolute handles both Unix and Windows) |
| 180 |
$is_absolute = path_is_absolute($path); |
| 181 |
|
| 182 |
// Normalize path separators for consistent handling |
| 183 |
$path = wp_normalize_path($path); |
| 184 |
|
| 185 |
// Handle relative paths by prepending ABSPATH |
| 186 |
if (!$is_absolute) { |
| 187 |
$path = path_join(ABSPATH, $path); |
| 188 |
} |
| 189 |
|
| 190 |
// Create directory with proper WordPress function and secure permissions |
| 191 |
if (!file_exists($path)) { |
| 192 |
wp_mkdir_p($path); |
| 193 |
} |
| 194 |
|
| 195 |
// Verify the directory exists and is writable |
| 196 |
if (!is_dir($path) || !wp_is_writable($path)) { |
| 197 |
// Fall back to WordPress uploads directory if target path is not writable |
| 198 |
$upload_dir = wp_upload_dir(); |
| 199 |
$path = $upload_dir['basedir'] . '/accua-forms'; |
| 200 |
wp_mkdir_p($path); |
| 201 |
} |
| 202 |
|
| 203 |
return $this->destPath = trailingslashit($path); |
| 204 |
} |
| 205 |
|
| 206 |
public function handle_upload($filedata){ |
| 207 |
$valid = true; |
| 208 |
$file = array( |
| 209 |
'dest_path' => $this->destPath, |
| 210 |
); |
| 211 |
|
| 212 |
if (!empty($filedata['error'])){ |
| 213 |
$valid = false; |
| 214 |
switch($filedata['error']){ |
| 215 |
case UPLOAD_ERR_INI_SIZE: |
| 216 |
case UPLOAD_ERR_FORM_SIZE: |
| 217 |
$file['errors'][] = $this->errorsText['size']; |
| 218 |
break; |
| 219 |
default: |
| 220 |
$file['errors'][] = $this->errorsText['upload']; |
| 221 |
} |
| 222 |
} else if ($filedata['size'] <= 0) { |
| 223 |
$valid = false; |
| 224 |
$file['errors'][] = $this->errorsText['empty']; |
| 225 |
} else if ($this->maxSize > 0 && $filedata['size'] > $this->maxSize) { |
| 226 |
$valid = false; |
| 227 |
$file['errors'][] = $this->errorsText['size']; |
| 228 |
} else { |
| 229 |
$file['size'] = $filedata['size']; |
| 230 |
} |
| 231 |
|
| 232 |
if (isset($filedata['name']) && $filedata['name'] !== ''){ |
| 233 |
$file['name'] = $filedata['name']; |
| 234 |
if ((strpos($file['name'], "\0") !== false) || (strpbrk($file['name'], "\1\2\3\4\5\6\7\10\11\12\13\14\15\16\17\20\21\22\23\24\25\26\27\30\31\32\33\34\35\36\37\177\\/:*?\"<>|") !== false) ) { |
| 235 |
$valid = false; |
| 236 |
$file['errors'][] = $this->errorsText['name']; |
| 237 |
} else if ($this->validExtensions) { |
| 238 |
$ext = strrchr($file['name'], '.'); |
| 239 |
$ext = ($ext === false) ? '' : strtolower(ltrim($ext, '.')); |
| 240 |
$valid_ext_lower = array_map('strtolower', $this->validExtensions); |
| 241 |
if (!in_array($ext, $valid_ext_lower, true)) { |
| 242 |
$valid = false; |
| 243 |
$file['errors'][] = str_replace('%extensions%', implode(', ',$this->validExtensions), $this->errorsText['ext']); |
| 244 |
} |
| 245 |
} |
| 246 |
} else { |
| 247 |
$file['name'] = ''; |
| 248 |
if ($valid) { |
| 249 |
$valid = false; |
| 250 |
$file['errors'][] = $this->errorsText['name']; |
| 251 |
} |
| 252 |
} |
| 253 |
if ($valid) { |
| 254 |
// Additional security check - validate MIME type |
| 255 |
$file_info = wp_check_filetype_and_ext($filedata['tmp_name'], $file['name']); |
| 256 |
if (empty($file_info['type'])) { |
| 257 |
$valid = false; |
| 258 |
$file['errors'][] = __('Invalid file type detected', 'contact-forms'); |
| 259 |
} else { |
| 260 |
// Make sure destination directory exists |
| 261 |
if (!is_dir($this->destPath)) { |
| 262 |
wp_mkdir_p($this->destPath); |
| 263 |
} |
| 264 |
|
| 265 |
// Generate a unique filename with sanitization |
| 266 |
do { |
| 267 |
$tmpname = 'tmp' . wp_generate_password(8, false) . '_' . sanitize_file_name($file['name']); |
| 268 |
} while (is_file($this->destPath.$tmpname)); |
| 269 |
|
| 270 |
// Move uploaded file |
| 271 |
// phpcs:ignore Generic.PHP.ForbiddenFunctions.Found -- move_uploaded_file is required for file uploads, no WP alternative |
| 272 |
$valid = move_uploaded_file($filedata['tmp_name'], $this->destPath.$tmpname); |
| 273 |
if ($valid) { |
| 274 |
$file['tmp_name'] = $tmpname; |
| 275 |
// Set proper file permissions |
| 276 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Direct chmod needed for upload permissions |
| 277 |
chmod($this->destPath.$tmpname, 0644); |
| 278 |
} else { |
| 279 |
$file['errors'][] = $this->errorsText['upload']; |
| 280 |
} |
| 281 |
} |
| 282 |
|
| 283 |
} |
| 284 |
|
| 285 |
return $file; |
| 286 |
} |
| 287 |
} |
| 288 |
|