PluginProbe
Hash Form – Drag & Drop Form Builder / trunk
Hash Form – Drag & Drop Form Builder vtrunk
1.4.4 1.4.3 1.4.2 1.4.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.6.1 1.2.7 1.2.8 1.2.9 1.3.0 All 47 releases
hash-form / includes / HashFormUploader.php

HashFormUploader.php in Hash Form – Drag & Drop Form Builder trunk, at includes/HashFormUploader.php

369 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 defined('ABSPATH') || die();
4
5 /**
6 * Handle file uploads via XMLHttpRequest
7 */
8 class HashFormUploadedFileXhr {
9
10 /**
11 * Save the file to the specified path
12 * @return boolean TRUE on success
13 */
14 public function save($path) {
15 global $wp_filesystem;
16
17 // Initialize the WordPress Filesystem API
18 if (empty($wp_filesystem)) {
19 require_once(ABSPATH . '/wp-admin/includes/file.php');
20 // Attempt direct connection; if it fails, this might prompt for credentials elsewhere
21 if (!WP_Filesystem()) {
22 // Log error or handle failure to initialize filesystem
23 // error_log('Failed to initialize WP_Filesystem.');
24 return false;
25 }
26 }
27
28 // Read the raw input stream into memory
29 // (This replaces the initial fopen("php://input"), stream_copy_to_stream, and fclose($input))
30 $raw_input_data = file_get_contents('php://input');
31
32 if ($raw_input_data === false) {
33 return false;
34 }
35
36 $realSize = strlen($raw_input_data);
37
38 // Perform size validation
39 if ($realSize != $this->getSize()) {
40 // Clear the data from memory
41 $raw_input_data = null;
42 return false;
43 }
44
45 // Write data to the target file using WP_Filesystem
46 // (This replaces tmpfile(), fseek(), fopen($path), stream_copy_to_stream, and fclose($target))
47 $result = $wp_filesystem->put_contents(
48 $path,
49 $raw_input_data,
50 FS_CHMOD_FILE // Optional: Sets recommended file permissions (e.g., 0644)
51 );
52
53 // Clear the data from memory after writing
54 $raw_input_data = null;
55
56 // Return success status
57 return $result; // put_contents returns true on success, false on failure.
58 }
59
60 function getName() {
61 return HashFormHelper::get_var('qqfile');
62 }
63
64 function getSize() {
65 // Callers treat 0 as an empty upload and report it; throwing here would
66 // surface as an uncaught fatal on the AJAX endpoint.
67 return isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
68 }
69
70 }
71
72 class HashFormFileUploader {
73
74 private $allowedExtensions = array();
75 private $sizeLimit = 10485760;
76 private $file;
77 private $error = '';
78
79 function __construct(array $allowedExtensions = array(), $sizeLimit = 10485760) {
80 $allowedExtensions = array_map('strtolower', $allowedExtensions);
81 //$unallowed_extensions = array('php', 'exe', 'ini', 'perl');
82 $exts = array_keys(get_allowed_mime_types());
83
84 $available_exts = array();
85 foreach ($exts as $ext) {
86 $array = explode('|', $ext);
87 foreach ($array as $a) {
88 $available_exts[] = $a;
89 }
90 }
91
92 $this->allowedExtensions = array_values(array_intersect($allowedExtensions, $available_exts));
93 $this->sizeLimit = $sizeLimit;
94 $this->checkServerSettings();
95
96 if (HashFormHelper::get_var('qqfile')) {
97 $this->file = new HashFormUploadedFileXhr();
98 } else {
99 $this->file = false;
100 }
101 }
102
103 private function checkServerSettings() {
104 $postSize = $this->toBytes(ini_get('post_max_size'));
105 $uploadSize = $this->toBytes(ini_get('upload_max_filesize'));
106
107 if ($postSize < $this->sizeLimit || $uploadSize < $this->sizeLimit) {
108 $size = max(1, $this->sizeLimit / 1024 / 1024) . 'M';
109 /* translators: 1: required size in megabytes, e.g. 10M */
110 $this->error = sprintf(esc_html__('Server error. Increase post_max_size and upload_max_filesize to %s.', 'hash-form'), $size);
111 }
112 }
113
114 private function toBytes($str) {
115 $val = trim($str);
116
117 // An unset ini directive returns an empty string.
118 if ('' === $val) {
119 return 0;
120 }
121
122 $last = strtolower($val[strlen($val) - 1]);
123 $val = floatval($val);
124 switch ($last) {
125 case 'g':
126 $val *= 1024 * 1024 * 1024;
127 break;
128 case 'm':
129 $val *= 1024 * 1024;
130 break;
131 case 'k':
132 $val *= 1024;
133 break;
134 }
135
136 return $val;
137 }
138
139 function handleUpload($uploadDirectory, $replaceOldFile = false, $upload_url = '') {
140 if ($this->error) {
141 return array('error' => $this->error);
142 }
143
144 $this->ensureUploadDirectory($uploadDirectory);
145 $uploadDirectory = trailingslashit($uploadDirectory . '/temp');
146 $upload_url = $upload_url . '/temp';
147 $unallowed_extensions = array('php', 'exe', 'ini', 'perl', 'asp');
148
149 global $wp_filesystem;
150
151 // Initialize the WP_Filesystem if not already done
152 if (!function_exists('WP_Filesystem')) {
153 require_once ABSPATH . 'wp-admin/includes/file.php';
154 }
155
156 if (!$wp_filesystem) {
157 WP_Filesystem();
158 }
159
160 if (!$wp_filesystem || !$wp_filesystem->is_writable($uploadDirectory)) {
161 return array('error' => esc_html__('Server error. Upload directory isn\'t writable.', 'hash-form'));
162 }
163
164 if (!$this->file) {
165 return array('error' => esc_html__('No files were uploaded.', 'hash-form'));
166 }
167
168 $size = $this->file->getSize();
169
170 if ($size == 0) {
171 return array('error' => esc_html__('File is empty', 'hash-form'));
172 }
173
174 if ($size > $this->sizeLimit) {
175 return array('error' => esc_html__('File is too large', 'hash-form'));
176 }
177
178 /*
179 * The visitor names the file, so the name is scrubbed before it is
180 * ever used to build a path. pathinfo() already drops any directory
181 * part, and sanitize_file_name() takes care of the rest: control
182 * characters, the shell and url metacharacters, the leading dots that
183 * would hide the file, and the double extensions ("shell.php.jpg")
184 * that some servers happily hand back to mod_php.
185 */
186 $pathinfo = pathinfo(sanitize_file_name(wp_basename($this->file->getName())));
187 $filename = isset($pathinfo['filename']) ? $pathinfo['filename'] : '';
188 $ext = isset($pathinfo['extension']) ? $pathinfo['extension'] : '';
189
190 // Nothing usable survived the scrub, or the name was only an
191 // extension. Rather than write a dotfile, give it one of our own.
192 if ('' === trim($filename)) {
193 $filename = 'file-' . wp_generate_password(8, false, false);
194 }
195
196 if ('' === $ext) {
197 return array('error' => esc_html__('This type of file is not allowed.', 'hash-form'));
198 }
199
200 if (in_array(strtolower($ext), $unallowed_extensions, true)) {
201 return array('error' => esc_html__('This type of file is not allowed.', 'hash-form'));
202 }
203
204 /*
205 * An empty list means nothing is permitted, not that everything is.
206 * This class is only ever constructed with a list the caller has
207 * already filtered, so an empty one is a caller that ended up with
208 * no usable extensions rather than one asking for no restriction.
209 */
210 if (!$this->allowedExtensions) {
211 return array('error' => esc_html__('This type of file is not allowed.', 'hash-form'));
212 }
213
214 if (!in_array(strtolower($ext), $this->allowedExtensions, true)) {
215 $these = implode(', ', $this->allowedExtensions);
216 return array('error' => esc_html__('File has an invalid extension, it should be one of', 'hash-form') . ' ' . $these . '.');
217 }
218
219 if (!$replaceOldFile) {
220 /// don't overwrite previous files that were uploaded
221 while (file_exists($uploadDirectory . $filename . '.' . $ext)) {
222 $filename .= wp_rand(10, 99);
223 }
224 }
225
226 $stored_name = $filename . '.' . $ext;
227 $stored_path = $uploadDirectory . $stored_name;
228
229 if (!$this->file->save($stored_path)) {
230 return array(
231 'error' => esc_html__('Could not save uploaded file. The upload was cancelled, or server error encountered.', 'hash-form')
232 );
233 }
234
235 /*
236 * Everything above this point trusts the extension the visitor typed.
237 * Now that the bytes are on disk they can be asked what they actually
238 * are, which is the only check a crafted upload cannot talk its way
239 * past. A file whose contents do not match its name is removed again
240 * rather than left sitting in a web-reachable directory.
241 */
242 $content_error = $this->verifyFileContents($stored_path, $stored_name);
243
244 if ($content_error) {
245 wp_delete_file($stored_path);
246 return array('error' => $content_error);
247 }
248
249 return array(
250 'success' => true,
251 'url' => $upload_url . '/' . $stored_name,
252 'path' => HashFormHelper::encrypt($stored_name)
253 );
254 }
255
256 /**
257 * Confirm the bytes on disk match the name they were given.
258 *
259 * @param string $path Absolute path to the freshly written file.
260 * @param string $name The name it was stored under.
261 * @return string Empty when the file is acceptable, otherwise the message
262 * to show the visitor.
263 */
264 protected function verifyFileContents($path, $name) {
265 if (!file_exists($path)) {
266 return esc_html__('Could not save uploaded file. The upload was cancelled, or server error encountered.', 'hash-form');
267 }
268
269 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
270
271 /*
272 * Reads the file's magic bytes where the server can (finfo), and
273 * compares the type it finds against the type the extension claims.
274 * On a host without fileinfo it falls back to the extension alone,
275 * which is no worse than the check that used to be here.
276 */
277 $check = wp_check_filetype_and_ext($path, $name);
278
279 if (empty($check['ext']) || empty($check['type'])) {
280 return esc_html__('This type of file is not allowed.', 'hash-form');
281 }
282
283 if (strtolower($check['ext']) !== $ext) {
284 return esc_html__('This file does not match its file type.', 'hash-form');
285 }
286
287 // An image extension has to open as an actual image. This is what
288 // stops a php payload wearing a .jpg on the end of its name.
289 $image_exts = array('jpg', 'jpeg', 'jpe', 'png', 'gif', 'bmp', 'webp', 'avif', 'ico');
290
291 if (in_array($ext, $image_exts, true) && function_exists('getimagesize')) {
292 $size = @getimagesize($path);
293
294 if (false === $size) {
295 return esc_html__('This file does not match its file type.', 'hash-form');
296 }
297 }
298
299 /*
300 * Belt and braces for the svg and html-ish types a site may have
301 * deliberately allowed: refuse anything carrying a php open tag, so
302 * a permissive mime list cannot become code execution on a server
303 * that ignores the .htaccess written alongside.
304 */
305 $head = file_get_contents($path, false, null, 0, 8192);
306
307 if (false !== $head && preg_match('/<\?php|<\?=/i', $head)) {
308 return esc_html__('This type of file is not allowed.', 'hash-form');
309 }
310
311 return '';
312 }
313
314 protected function ensureUploadDirectory($path) {
315 global $wp_filesystem;
316
317 // Initialize the WP_Filesystem if not already done
318 if (!function_exists('WP_Filesystem')) {
319 require_once ABSPATH . 'wp-admin/includes/file.php';
320 }
321
322 if (!$wp_filesystem) {
323 WP_Filesystem();
324 }
325
326 // WP_Filesystem() returns false when it cannot connect, leaving the
327 // global unset; handleUpload() reports the unwritable directory.
328 if (!$wp_filesystem) {
329 return;
330 }
331
332 $htaccess = $wp_filesystem->get_contents(HASHFORM_PATH . 'admin/stubs/htaccess.stub');
333 $index = $wp_filesystem->get_contents(HASHFORM_PATH . 'admin/stubs/index.stub');
334
335 // Omitting the mode lets WP_Filesystem apply FS_CHMOD_FILE itself; the
336 // constant is only defined once WP_Filesystem() has run.
337 if (!is_dir($path)) {
338 $wp_filesystem->mkdir($path, 0755);
339 }
340
341 if (!is_dir($path . '/temp')) {
342 $wp_filesystem->mkdir($path . '/temp', 0755);
343 }
344
345 /*
346 * Written whenever it is absent rather than only alongside a fresh
347 * mkdir. A directory left behind by a version that did not write these
348 * rules would otherwise never receive them, and this file is what stops
349 * the handlers running in here.
350 */
351 if (is_dir($path) && !file_exists($path . '/.htaccess')) {
352 $wp_filesystem->put_contents($path . '/.htaccess', $htaccess);
353 }
354
355 if (is_dir($path . '/temp') && !file_exists($path . '/temp/.htaccess')) {
356 $wp_filesystem->put_contents($path . '/temp/.htaccess', $htaccess);
357 }
358
359 if (!file_exists($path . '/index.php')) {
360 $wp_filesystem->put_contents($path . '/index.php', $index);
361 }
362
363 if (!file_exists($path . '/temp/index.php')) {
364 $wp_filesystem->put_contents($path . '/temp/index.php', $index);
365 }
366 }
367
368 }
369